spatial-computing 0.5.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.
Files changed (58) hide show
  1. spatial/__init__.py +6 -0
  2. spatial/ai/__init__.py +18 -0
  3. spatial/ai/kvcache.py +81 -0
  4. spatial/cli.py +135 -0
  5. spatial/compute/__init__.py +3 -0
  6. spatial/compute/cost.py +69 -0
  7. spatial/compute/detect.py +139 -0
  8. spatial/compute/monitor.py +111 -0
  9. spatial/compute/scheduler.py +168 -0
  10. spatial/compute/taskqueue.py +107 -0
  11. spatial/compute/worker.py +89 -0
  12. spatial/compute/workloads.py +148 -0
  13. spatial/core/__init__.py +1 -0
  14. spatial/core/energy.py +101 -0
  15. spatial/core/ledger.py +76 -0
  16. spatial/core/model.py +82 -0
  17. spatial/core/protocol.py +167 -0
  18. spatial/core/registry.py +59 -0
  19. spatial/holographic/__init__.py +8 -0
  20. spatial/holographic/cgh.py +40 -0
  21. spatial/holographic/model.py +49 -0
  22. spatial/intelligence/__init__.py +6 -0
  23. spatial/intelligence/predictor.py +51 -0
  24. spatial/memory/__init__.py +5 -0
  25. spatial/memory/fararray.py +44 -0
  26. spatial/memory/pageserver.py +141 -0
  27. spatial/memory/secure.py +100 -0
  28. spatial/memory/shared.py +43 -0
  29. spatial/memory/tiered.py +355 -0
  30. spatial/q/__init__.py +4 -0
  31. spatial/q/__main__.py +9 -0
  32. spatial/q/backends/__init__.py +122 -0
  33. spatial/q/codegen.py +153 -0
  34. spatial/q/compiler.py +276 -0
  35. spatial/q/ir.py +121 -0
  36. spatial/q/lexer.py +61 -0
  37. spatial/q/parser.py +330 -0
  38. spatial/q/qir.py +128 -0
  39. spatial/q/runtime.py +274 -0
  40. spatial/q/sim.py +218 -0
  41. spatial/runtime/__init__.py +3 -0
  42. spatial/runtime/envs.py +74 -0
  43. spatial/runtime/inspect.py +48 -0
  44. spatial/runtime/microvm.py +125 -0
  45. spatial/runtime/planner.py +155 -0
  46. spatial/runtime/runner.py +108 -0
  47. spatial/runtime/toolchain.py +35 -0
  48. spatial/scientific/__init__.py +8 -0
  49. spatial/scientific/jobs.py +80 -0
  50. spatial/simulation/__init__.py +7 -0
  51. spatial/simulation/heat.py +48 -0
  52. spatial/simulation/nbody.py +31 -0
  53. spatial_computing-0.5.0.dist-info/METADATA +112 -0
  54. spatial_computing-0.5.0.dist-info/RECORD +58 -0
  55. spatial_computing-0.5.0.dist-info/WHEEL +5 -0
  56. spatial_computing-0.5.0.dist-info/entry_points.txt +3 -0
  57. spatial_computing-0.5.0.dist-info/licenses/LICENSE +202 -0
  58. spatial_computing-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,168 @@
1
+ """Strategy comparison and selection for the Spatial Compute Engine.
2
+
3
+ compare(): run every feasible strategy for a task, MEASURE it, verify the result
4
+ matches the reference, then choose() by objective (time | cpu | energy).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import multiprocessing as mp
9
+ import threading
10
+ from concurrent.futures import ProcessPoolExecutor
11
+ from dataclasses import dataclass
12
+ from typing import Callable
13
+
14
+ from ..core.ledger import Ledger
15
+ from ..core.model import Measurement, Node, ResourceKind
16
+ from . import workloads
17
+ from .monitor import Measure
18
+
19
+
20
+ def _chunk(args):
21
+ name, params = args
22
+ return workloads.run(name, **params)
23
+
24
+
25
+ def _row_chunks(n: int, parts: int) -> list[tuple[int, int]]:
26
+ step = max(1, n // parts)
27
+ return [(r, min(n, r + step)) for r in range(0, n, step)]
28
+
29
+
30
+ @dataclass
31
+ class Strategy:
32
+ name: str
33
+ fn: Callable[[dict], object]
34
+ kind: str # local | optimized | parallel | distributed | remote
35
+ needs_worker: bool = False
36
+
37
+
38
+ class ComputeScheduler:
39
+ def __init__(self, node: Node, ledger: Ledger | None = None, worker_client=None):
40
+ self.node = node
41
+ self.ledger = ledger or Ledger()
42
+ self.worker = worker_client
43
+ self.cores = int(node.by_kind(ResourceKind.CPU)[0].capacity)
44
+ self._pool = None
45
+
46
+ # ---- mandelbrot family -------------------------------------------------
47
+ def mandel_strategies(self) -> list[Strategy]:
48
+ S = [
49
+ Strategy("local_python", lambda p: workloads.mandel_python(**p), "local"),
50
+ Strategy("local_numpy", lambda p: workloads.mandel_numpy(**p), "optimized"),
51
+ ]
52
+ if workloads.HAVE_NUMBA:
53
+ S += [
54
+ Strategy("local_numba_jit", lambda p: workloads.mandel_numba(**p), "optimized"),
55
+ Strategy("local_numba_threads", lambda p: workloads.mandel_numba_parallel(**p), "parallel"),
56
+ Strategy("multiprocess_numba", self._mp, "parallel"),
57
+ Strategy("multiprocess_warm_pool", self._mp_warm, "parallel"),
58
+ ]
59
+ if self.worker is not None:
60
+ S += [
61
+ Strategy("remote_worker", self._remote, "remote", needs_worker=True),
62
+ Strategy("distributed_local+worker", self._distributed, "distributed", needs_worker=True),
63
+ ]
64
+ return S
65
+
66
+ def _mp(self, p: dict):
67
+ chunks = _row_chunks(p["n"], self.cores * 8)
68
+ with ProcessPoolExecutor(self.cores, mp_context=mp.get_context("spawn")) as ex:
69
+ return sum(ex.map(_chunk, [("mandel_numba", {**p, "r0": a, "r1": b}) for a, b in chunks]))
70
+
71
+ def _mp_warm(self, p: dict):
72
+ """Same split as _mp but on a pool started (and JIT-warmed) beforehand: isolates the
73
+ cost of process start-up + imports that _mp pays on every call."""
74
+ if self._pool is None:
75
+ self._start_pool()
76
+ chunks = _row_chunks(p["n"], self.cores * 8)
77
+ return sum(self._pool.map(_chunk, [("mandel_numba", {**p, "r0": a, "r1": b}) for a, b in chunks]))
78
+
79
+ def _start_pool(self):
80
+ self._pool = ProcessPoolExecutor(self.cores, mp_context=mp.get_context("spawn"))
81
+ list(self._pool.map(_chunk, [("mandel_numba", {"n": 8, "iters": 8})] * (self.cores * 4)))
82
+
83
+ def close(self):
84
+ if self._pool is not None:
85
+ self._pool.shutdown()
86
+ self._pool = None
87
+
88
+ def _remote(self, p: dict):
89
+ rh, _ = self.worker.call({"op": "run", "workload": "mandel_numba", "params": p})
90
+ self._last_remote = rh
91
+ return rh["result"]
92
+
93
+ def _distributed(self, p: dict):
94
+ """Interleave row chunks: odd chunks to the worker, even chunks locally."""
95
+ chunks = _row_chunks(p["n"], 32)
96
+ mine, theirs = chunks[0::2], chunks[1::2]
97
+ out = {}
98
+
99
+ def remote():
100
+ tot = 0
101
+ for a, b in theirs:
102
+ rh, _ = self.worker.call({"op": "run", "workload": "mandel_numba",
103
+ "params": {**p, "r0": a, "r1": b}})
104
+ tot += rh["result"]
105
+ out["r"] = tot
106
+
107
+ t = threading.Thread(target=remote)
108
+ t.start()
109
+ local = sum(workloads.mandel_numba(**{**p, "r0": a, "r1": b}) for a, b in mine)
110
+ t.join()
111
+ return local + out["r"]
112
+
113
+ # ---- generic -----------------------------------------------------------
114
+ def warmup(self):
115
+ """Compile JIT code outside the timed region (compile time is reported separately)."""
116
+ if workloads.HAVE_NUMBA:
117
+ workloads.mandel_numba(8, 8)
118
+ workloads.mandel_numba_parallel(8, 8)
119
+ if self._pool is None:
120
+ self._start_pool()
121
+ if self.worker is not None:
122
+ self.worker.call({"op": "run", "workload": "mandel_numba", "params": {"n": 8, "iters": 8}})
123
+
124
+ def compare(self, experiment: str, params: dict, strategies: list[Strategy],
125
+ reference: object | None = None, skip: set[str] = frozenset()) -> list[Measurement]:
126
+ rows = []
127
+ for s in strategies:
128
+ if s.name in skip:
129
+ continue
130
+ holder = {}
131
+ with Measure("compute", experiment, s.name) as m:
132
+ holder["r"] = s.fn(dict(params))
133
+ m.metrics["kind"] = s.kind
134
+ m.metrics["params"] = params
135
+ if m.ok:
136
+ if reference is None:
137
+ reference = holder["r"]
138
+ m.metrics["result"] = holder["r"]
139
+ m.metrics["matches_reference"] = holder["r"] == reference
140
+ if not m.metrics["matches_reference"]:
141
+ m.ok = False
142
+ m.notes = "result differs from reference"
143
+ if s.needs_worker and m.ok:
144
+ m.notes = "worker is a separate process on this same machine (loopback)"
145
+ rows.append(self.ledger.record(m))
146
+ return rows
147
+
148
+ def predict_best(self, n: int, iters: int) -> tuple[str, float] | None:
149
+ """Ask the Intelligence Engine which strategy should be fastest for this size, from the
150
+ ledger's past measurements, WITHOUT running anything. None when history is too thin."""
151
+ from ..intelligence import PowerLawPredictor
152
+ return PowerLawPredictor.from_ledger(self.ledger.rows()).best(n * n * iters)
153
+
154
+ def choose(self, rows: list[Measurement], objective: str = "time") -> Measurement:
155
+ ok = [r for r in rows if r.ok]
156
+ if not ok:
157
+ raise RuntimeError("no strategy succeeded")
158
+ if objective == "cost": # $ = CPU-seconds actually burnt, priced per core-hour
159
+ usd_core_h = getattr(self, "usd_per_core_hour", 0.045) # assumption, override on the instance
160
+ for r in ok:
161
+ r.cost_usd = (r.cpu_s or r.wall_s) / 3600 * usd_core_h
162
+ key = {"time": lambda r: r.wall_s, "cpu": lambda r: r.cpu_s, "cost": lambda r: r.cost_usd,
163
+ "energy": lambda r: r.energy_j if r.energy_j is not None else float("inf")}[objective]
164
+ best = min(ok, key=key)
165
+ self.ledger.decide("compute", f"choose {best.strategy}", f"min {objective}",
166
+ experiment=best.experiment,
167
+ candidates={r.strategy: round(key(r), 4) for r in ok if key(r) != float("inf")})
168
+ return best
@@ -0,0 +1,107 @@
1
+ """Priority task queue over a local executor slot and N remote workers.
2
+
3
+ Tasks are allow-listed workloads (name + params), so they are safe to re-run: when a
4
+ worker fails mid-task the task is re-queued and the worker is retired. Lower `priority`
5
+ runs first; ties keep submission order.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import heapq
10
+ import itertools
11
+ import threading
12
+ import time
13
+ from concurrent.futures import Future
14
+ from dataclasses import dataclass, field
15
+
16
+ from . import workloads
17
+
18
+
19
+ @dataclass(order=True)
20
+ class _Item:
21
+ priority: int
22
+ seq: int
23
+ workload: str = field(compare=False)
24
+ params: dict = field(compare=False)
25
+ future: Future = field(compare=False)
26
+ attempts: int = field(compare=False, default=0)
27
+
28
+
29
+ class TaskQueue:
30
+ def __init__(self, workers: dict | None = None, local_slots: int = 1, max_attempts: int = 3):
31
+ """workers: {name: RpcClient}. local_slots: tasks run concurrently in this process."""
32
+ self._heap: list[_Item] = []
33
+ self._cv = threading.Condition()
34
+ self._seq = itertools.count()
35
+ self._closed = False
36
+ self.max_attempts = max_attempts
37
+ self.log: list[dict] = [] # one entry per finished attempt: who ran what, how long
38
+ self.retired: list[str] = []
39
+ self._threads = []
40
+ for i in range(local_slots):
41
+ self._spawn(f"local-{i}", None)
42
+ for name, client in (workers or {}).items():
43
+ self._spawn(name, client)
44
+
45
+ def _spawn(self, name, client):
46
+ t = threading.Thread(target=self._loop, args=(name, client), daemon=True)
47
+ t.start()
48
+ self._threads.append(t)
49
+
50
+ def submit(self, workload: str, params: dict | None = None, priority: int = 10) -> Future:
51
+ if workload not in workloads.REGISTRY:
52
+ raise KeyError(f"workload '{workload}' is not allow-listed")
53
+ f: Future = Future()
54
+ with self._cv:
55
+ if self._closed:
56
+ raise RuntimeError("queue is closed")
57
+ heapq.heappush(self._heap, _Item(priority, next(self._seq), workload, params or {}, f))
58
+ self._cv.notify()
59
+ return f
60
+
61
+ def _loop(self, name, client):
62
+ while True:
63
+ with self._cv:
64
+ while not self._heap and not self._closed:
65
+ self._cv.wait()
66
+ if not self._heap:
67
+ return
68
+ item = heapq.heappop(self._heap)
69
+ t0 = time.perf_counter()
70
+ try:
71
+ if client is None:
72
+ result = workloads.run(item.workload, **item.params)
73
+ else:
74
+ result = client.call({"op": "run", "workload": item.workload, "params": item.params})[0]["result"]
75
+ except (ConnectionError, OSError) as e: # executor is gone: give the task back
76
+ item.attempts += 1
77
+ self.log.append({"executor": name, "workload": item.workload, "ok": False, "error": str(e)})
78
+ with self._cv:
79
+ self.retired.append(name)
80
+ alive = sum(t.is_alive() for t in self._threads) - 1
81
+ if item.attempts >= self.max_attempts or alive <= 0:
82
+ item.future.set_exception(RuntimeError(f"task failed after {item.attempts} attempt(s): {e}"))
83
+ else:
84
+ heapq.heappush(self._heap, item)
85
+ self._cv.notify()
86
+ return # retire this executor
87
+ except Exception as e: # the task itself is wrong: do not retry
88
+ self.log.append({"executor": name, "workload": item.workload, "ok": False, "error": str(e)})
89
+ item.future.set_exception(e)
90
+ continue
91
+ self.log.append({"executor": name, "workload": item.workload, "priority": item.priority,
92
+ "ok": True, "wall_s": time.perf_counter() - t0})
93
+ item.future.set_result(result)
94
+
95
+ def close(self, wait: bool = True):
96
+ with self._cv:
97
+ self._closed = True
98
+ self._cv.notify_all()
99
+ if wait:
100
+ for t in self._threads:
101
+ t.join(timeout=60)
102
+
103
+ def __enter__(self):
104
+ return self
105
+
106
+ def __exit__(self, *a):
107
+ self.close()
@@ -0,0 +1,89 @@
1
+ """Compute worker: executes whitelisted workloads by name over SWP/0.
2
+
3
+ Run: python -m spatial.compute.worker --token-file <path> [--port 0]
4
+ Binds 127.0.0.1 only. Exposing it on a LAN requires --allow-remote AND --host,
5
+ which is a deliberate, explicit act by the machine owner.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import secrets
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from ..core.protocol import RpcClient, RpcServer
18
+ from . import workloads
19
+
20
+
21
+ def _handler(header: dict, blob: bytes):
22
+ op = header.get("op")
23
+ if op == "ping":
24
+ return {"pong": True, "pid": os.getpid()}, b""
25
+ if op == "run":
26
+ t = os.times()
27
+ c0, w0 = t.user + t.system, time.perf_counter()
28
+ result = workloads.run(header["workload"], **header.get("params", {}))
29
+ t = os.times()
30
+ return {"result": result, "wall_s": time.perf_counter() - w0,
31
+ "cpu_s": t.user + t.system - c0}, b""
32
+ raise ValueError(f"unknown op {op!r}")
33
+
34
+
35
+ def serve(token: bytes, host: str = "127.0.0.1", port: int = 0, allow_remote: bool = False) -> RpcServer:
36
+ return RpcServer(_handler, token, host=host, port=port, allow_remote=allow_remote)
37
+
38
+
39
+ class LocalWorkerProcess:
40
+ """Spawns a worker as a separate OS process on loopback (stand-in for a second machine:
41
+ it validates the protocol and its overhead, NOT the gain of extra hardware)."""
42
+
43
+ def __init__(self):
44
+ self.token = secrets.token_bytes(32)
45
+ self._tf = Path(os.environ.get("TMPDIR", "/tmp")) / f"spatial-token-{os.getpid()}-{secrets.token_hex(4)}"
46
+ self._tf.write_bytes(self.token)
47
+ os.chmod(self._tf, 0o600)
48
+ root = Path(__file__).resolve().parents[2]
49
+ self.proc = subprocess.Popen(
50
+ [sys.executable, "-m", "spatial.compute.worker", "--token-file", str(self._tf)],
51
+ cwd=root, stdout=subprocess.PIPE, text=True)
52
+ line = self.proc.stdout.readline()
53
+ if not line.startswith("PORT "):
54
+ raise RuntimeError(f"worker failed to start: {line!r}")
55
+ self.port = int(line.split()[1])
56
+
57
+ def client(self) -> RpcClient:
58
+ return RpcClient("127.0.0.1", self.port, self.token)
59
+
60
+ def stop(self):
61
+ self.proc.terminate()
62
+ try:
63
+ self.proc.wait(timeout=5)
64
+ except subprocess.TimeoutExpired:
65
+ self.proc.kill()
66
+ self._tf.unlink(missing_ok=True)
67
+
68
+ def __enter__(self):
69
+ return self
70
+
71
+ def __exit__(self, *a):
72
+ self.stop()
73
+
74
+
75
+ def main():
76
+ ap = argparse.ArgumentParser()
77
+ ap.add_argument("--token-file", required=True)
78
+ ap.add_argument("--host", default="127.0.0.1")
79
+ ap.add_argument("--port", type=int, default=0)
80
+ ap.add_argument("--allow-remote", action="store_true",
81
+ help="explicit consent to accept non-loopback connections")
82
+ a = ap.parse_args()
83
+ srv = serve(Path(a.token_file).read_bytes(), a.host, a.port, a.allow_remote)
84
+ print(f"PORT {srv.port}", flush=True)
85
+ srv.serve_forever()
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
@@ -0,0 +1,148 @@
1
+ """Whitelisted workloads. Every variant of a workload must return the same checksum,
2
+ so a faster strategy is only accepted if it computes the same thing."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+
8
+ import numpy as np
9
+
10
+ # workqueue: no TBB/OpenMP dependency, safe with subprocess use from helper threads
11
+ os.environ.setdefault("NUMBA_THREADING_LAYER", "workqueue")
12
+
13
+ try:
14
+ import numba
15
+ from numba import njit, prange
16
+ HAVE_NUMBA = True
17
+ except Exception: # pragma: no cover
18
+ HAVE_NUMBA = False
19
+
20
+ # Mandelbrot window
21
+ X0, X1, Y0, Y1 = -2.0, 0.6, -1.3, 1.3
22
+
23
+
24
+ def mandel_python(n: int, iters: int, r0: int = 0, r1: int | None = None) -> int:
25
+ r1 = n if r1 is None else r1
26
+ total = 0
27
+ for i in range(r0, r1):
28
+ ci = Y0 + (Y1 - Y0) * i / n
29
+ for j in range(n):
30
+ cr = X0 + (X1 - X0) * j / n
31
+ zr = zi = 0.0
32
+ k = 0
33
+ while k < iters and zr * zr + zi * zi <= 4.0:
34
+ zr, zi = zr * zr - zi * zi + cr, 2.0 * zr * zi + ci
35
+ k += 1
36
+ total += k
37
+ return total
38
+
39
+
40
+ def mandel_numpy(n: int, iters: int, r0: int = 0, r1: int | None = None) -> int:
41
+ r1 = n if r1 is None else r1
42
+ ci = (Y0 + (Y1 - Y0) * np.arange(r0, r1) / n)[:, None]
43
+ cr = (X0 + (X1 - X0) * np.arange(n) / n)[None, :]
44
+ c = cr + 1j * ci
45
+ z = np.zeros_like(c)
46
+ count = np.zeros(c.shape, dtype=np.int64)
47
+ alive = np.ones(c.shape, dtype=bool)
48
+ for _ in range(iters):
49
+ alive &= (z.real * z.real + z.imag * z.imag) <= 4.0
50
+ if not alive.any():
51
+ break
52
+ count[alive] += 1
53
+ z[alive] = z[alive] * z[alive] + c[alive]
54
+ return int(count.sum())
55
+
56
+
57
+ if HAVE_NUMBA:
58
+ @njit(cache=True, fastmath=False, nogil=True) # nogil: usable from threads
59
+ def _mandel_nb(n, iters, r0, r1):
60
+ total = 0
61
+ for i in range(r0, r1):
62
+ ci = Y0 + (Y1 - Y0) * i / n
63
+ for j in range(n):
64
+ cr = X0 + (X1 - X0) * j / n
65
+ zr = 0.0
66
+ zi = 0.0
67
+ k = 0
68
+ while k < iters and zr * zr + zi * zi <= 4.0:
69
+ t = zr * zr - zi * zi + cr
70
+ zi = 2.0 * zr * zi + ci
71
+ zr = t
72
+ k += 1
73
+ total += k
74
+ return total
75
+
76
+ @njit(cache=True, parallel=True, fastmath=False)
77
+ def _mandel_nb_par(n, iters, r0, r1):
78
+ total = 0
79
+ for i in prange(r0, r1):
80
+ ci = Y0 + (Y1 - Y0) * i / n
81
+ row = 0
82
+ for j in range(n):
83
+ cr = X0 + (X1 - X0) * j / n
84
+ zr = 0.0
85
+ zi = 0.0
86
+ k = 0
87
+ while k < iters and zr * zr + zi * zi <= 4.0:
88
+ t = zr * zr - zi * zi + cr
89
+ zi = 2.0 * zr * zi + ci
90
+ zr = t
91
+ k += 1
92
+ row += k
93
+ total += row
94
+ return total
95
+
96
+
97
+ def mandel_numba(n: int, iters: int, r0: int = 0, r1: int | None = None) -> int:
98
+ return int(_mandel_nb(n, iters, r0, n if r1 is None else r1))
99
+
100
+
101
+ def mandel_numba_parallel(n: int, iters: int, r0: int = 0, r1: int | None = None) -> int:
102
+ return int(_mandel_nb_par(n, iters, r0, n if r1 is None else r1))
103
+
104
+
105
+ def montecarlo_pi(samples: int, seed: int = 0) -> float:
106
+ rng = np.random.default_rng(seed)
107
+ inside = 0
108
+ left = samples
109
+ while left > 0:
110
+ k = min(left, 2_000_000)
111
+ xy = rng.random((k, 2))
112
+ inside += int(((xy * xy).sum(axis=1) <= 1.0).sum())
113
+ left -= k
114
+ return 4.0 * inside / samples
115
+
116
+
117
+ def sha256_chain(rounds: int, size: int = 1 << 20) -> str:
118
+ """Hash-bound workload (memory-bandwidth + SHA extensions if any)."""
119
+ buf = bytes(size)
120
+ h = hashlib.sha256()
121
+ for _ in range(rounds):
122
+ h.update(buf)
123
+ return h.hexdigest()
124
+
125
+
126
+ def matmul(n: int, seed: int = 0) -> float:
127
+ rng = np.random.default_rng(seed)
128
+ a = rng.random((n, n), dtype=np.float32)
129
+ b = rng.random((n, n), dtype=np.float32)
130
+ return float((a @ b).trace())
131
+
132
+
133
+ REGISTRY = {
134
+ "mandel_python": mandel_python,
135
+ "mandel_numpy": mandel_numpy,
136
+ "montecarlo_pi": montecarlo_pi,
137
+ "sha256_chain": sha256_chain,
138
+ "matmul": matmul,
139
+ }
140
+ if HAVE_NUMBA:
141
+ REGISTRY["mandel_numba"] = mandel_numba
142
+ REGISTRY["mandel_numba_parallel"] = mandel_numba_parallel
143
+
144
+
145
+ def run(name: str, **params):
146
+ if name not in REGISTRY:
147
+ raise KeyError(f"workload '{name}' is not whitelisted")
148
+ return REGISTRY[name](**params)
@@ -0,0 +1 @@
1
+ from .model import Resource, Node, Task, Measurement, ResourceKind # noqa: F401
spatial/core/energy.py ADDED
@@ -0,0 +1,101 @@
1
+ """Whole-machine power sampling from the battery gauge (no sudo).
2
+
3
+ MEASURED LIMITATION (MacBookPro14,1, 2026-11-14): the gauge value exposed by ioreg stayed
4
+ constant (32.5 W) for 24 s across idle -> full CPU load -> idle. Its refresh period is far
5
+ longer than a benchmark run, so per-run energy from this source is only
6
+ "last gauge reading x duration". `PowerSampler.static` flags that case; callers must not
7
+ derive marginal (above-idle) energy from it. Proper per-run energy needs `powermetrics`
8
+ (root) or an external power meter.
9
+
10
+ Only valid on battery power: on AC the gauge reports charge current, not system
11
+ draw, so `available()` is False and energy is reported as None (never guessed).
12
+ The value is the power of the WHOLE machine (screen included), so benchmarks
13
+ report it next to an idle baseline.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import subprocess
19
+ import threading
20
+ import time
21
+
22
+
23
+ def _read() -> tuple[float, bool] | None:
24
+ """Returns (watts drawn, on_battery) or None when no gauge is present."""
25
+ try:
26
+ out = subprocess.run(["ioreg", "-rn", "AppleSmartBattery"], capture_output=True,
27
+ text=True, timeout=5).stdout
28
+ except Exception:
29
+ return None
30
+ amp = re.search(r'^\s*"InstantAmperage" = (\d+)', out, re.M)
31
+ volt = re.search(r'^\s*"Voltage" = (\d+)', out, re.M)
32
+ ext = re.search(r'^\s*"ExternalConnected" = (\w+)', out, re.M)
33
+ if not (amp and volt and ext):
34
+ return None
35
+ ma = int(amp.group(1))
36
+ if ma >= 1 << 63: # gauge exposes a signed value as u64
37
+ ma -= 1 << 64
38
+ watts = abs(ma) / 1000.0 * int(volt.group(1)) / 1000.0
39
+ return watts, ext.group(1) == "No"
40
+
41
+
42
+ def available() -> bool:
43
+ r = _read()
44
+ return bool(r and r[1])
45
+
46
+
47
+ class PowerSampler:
48
+ """Context manager: integrates battery power over the block (trapezoid)."""
49
+
50
+ def __init__(self, period_s: float = 0.5):
51
+ self.period = period_s
52
+ self.samples: list[tuple[float, float]] = []
53
+ self._stop = threading.Event()
54
+ self._t: threading.Thread | None = None
55
+ self.valid = False
56
+
57
+ def _loop(self):
58
+ while not self._stop.is_set():
59
+ r = _read()
60
+ if r is None or not r[1]:
61
+ self.valid = False
62
+ return
63
+ self.samples.append((time.perf_counter(), r[0]))
64
+ self._stop.wait(self.period)
65
+
66
+ def __enter__(self):
67
+ self.valid = available()
68
+ if self.valid:
69
+ self._t = threading.Thread(target=self._loop, daemon=True)
70
+ self._t.start()
71
+ self.t0 = time.perf_counter()
72
+ return self
73
+
74
+ def __exit__(self, *exc):
75
+ self.t1 = time.perf_counter()
76
+ self._stop.set()
77
+ if self._t:
78
+ self._t.join(timeout=6)
79
+ return False
80
+
81
+ @property
82
+ def mean_watts(self) -> float | None:
83
+ if not self.valid or not self.samples:
84
+ return None
85
+ return sum(w for _, w in self.samples) / len(self.samples)
86
+
87
+ @property
88
+ def static(self) -> bool:
89
+ """True when the gauge never changed during the window (reading is stale/coarse)."""
90
+ return len({w for _, w in self.samples}) <= 1
91
+
92
+ @property
93
+ def joules(self) -> float | None:
94
+ mw = self.mean_watts
95
+ return None if mw is None else mw * (self.t1 - self.t0)
96
+
97
+
98
+ def idle_watts(seconds: float = 4.0) -> float | None:
99
+ with PowerSampler(0.5) as p:
100
+ time.sleep(seconds)
101
+ return p.mean_watts
spatial/core/ledger.py ADDED
@@ -0,0 +1,76 @@
1
+ """Append-only, hash-chained JSONL ledger of measurements and decisions (the R&D trace).
2
+
3
+ Every row carries `prev` (hash of the previous row) and `hash` = SHA-256(prev | canonical
4
+ JSON of the row). `verify()` detects an edited, removed or reordered row. This is tamper
5
+ EVIDENCE, not a signature: someone who can rewrite the whole file can rebuild the chain.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from .model import Measurement
16
+
17
+ ROOT = Path(__file__).resolve().parents[2]
18
+ RESULTS = ROOT / "benchmarks" / "results"
19
+ GENESIS = "0" * 64
20
+
21
+
22
+ def _digest(prev: str, row: dict) -> str:
23
+ body = json.dumps(row, sort_keys=True, separators=(",", ":"), default=str)
24
+ return hashlib.sha256((prev + body).encode()).hexdigest()
25
+
26
+
27
+ class Ledger:
28
+ def __init__(self, path: str | os.PathLike | None = None):
29
+ self.path = Path(path) if path else RESULTS / "ledger.jsonl"
30
+ self.path.parent.mkdir(parents=True, exist_ok=True)
31
+ self._last = self._tail_hash()
32
+
33
+ def _tail_hash(self) -> str:
34
+ last = GENESIS
35
+ if self.path.exists():
36
+ for line in self.path.read_text().splitlines():
37
+ if line.strip():
38
+ last = json.loads(line).get("hash", last)
39
+ return last
40
+
41
+ def _append(self, row: dict) -> None:
42
+ row = json.loads(json.dumps(row, default=str)) # normalise before hashing
43
+ h = _digest(self._last, row)
44
+ with open(self.path, "a") as f:
45
+ f.write(json.dumps({**row, "prev": self._last, "hash": h}) + "\n")
46
+ self._last = h
47
+
48
+ def record(self, m: Measurement) -> Measurement:
49
+ self._append({"type": "measurement", **m.to_dict()})
50
+ return m
51
+
52
+ def decide(self, engine: str, decision: str, reason: str, **data) -> None:
53
+ self._append({"type": "decision", "ts": time.time(), "engine": engine,
54
+ "decision": decision, "reason": reason, **data})
55
+
56
+ def rows(self, type_: str | None = None) -> list[dict]:
57
+ if not self.path.exists():
58
+ return []
59
+ out = []
60
+ for line in self.path.read_text().splitlines():
61
+ if line.strip():
62
+ r = json.loads(line)
63
+ if type_ is None or r.get("type") == type_:
64
+ out.append(r)
65
+ return out
66
+
67
+ def verify(self) -> tuple[bool, int | None]:
68
+ """(True, None) when the chain is intact, else (False, index of the first bad row).
69
+ Rows written before chaining existed (no `hash`) are reported as bad."""
70
+ prev = GENESIS
71
+ for i, r in enumerate(self.rows()):
72
+ h, p = r.pop("hash", None), r.pop("prev", None)
73
+ if h is None or p != prev or _digest(prev, r) != h:
74
+ return False, i
75
+ prev = h
76
+ return True, None