simit 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.
@@ -0,0 +1,197 @@
1
+ """Deterministic helpers shared across renderers: geometry notation helpers
2
+ (injected into the model's sandbox) and automatic text-overlap avoidance
3
+ (used both in-process by the deterministic renderers and injected into the
4
+ model's sandbox)."""
5
+
6
+ # Pre-defined geometry notation helpers, injected ahead of every model-written
7
+ # script so the model calls them by name instead of constructing this
8
+ # notation by hand -- it's unreliable at hand-rolling right-angle squares,
9
+ # equal-length tick marks, and angle arcs from scratch (wrong vertex, wrong
10
+ # offset direction, text overlapping the shape), but reliable at calling a
11
+ # documented function with the right arguments (see MASTER_PROMPT Example 26).
12
+ GEOMETRY_HELPERS_SRC = '''
13
+ import numpy as np
14
+ from matplotlib.patches import Polygon as _HelperPolygon, Arc as _HelperArc
15
+
16
+ def right_angle_marker(ax, vertex, p1, p2, size=0.3, **kwargs):
17
+ vertex, p1, p2 = np.array(vertex, dtype=float), np.array(p1, dtype=float), np.array(p2, dtype=float)
18
+ u1 = (p1 - vertex) / np.linalg.norm(p1 - vertex)
19
+ u2 = (p2 - vertex) / np.linalg.norm(p2 - vertex)
20
+ pts = [vertex, vertex + size * u1, vertex + size * u1 + size * u2, vertex + size * u2]
21
+ kwargs.setdefault("fill", False)
22
+ kwargs.setdefault("edgecolor", "black")
23
+ kwargs.setdefault("linewidth", 1.2)
24
+ ax.add_patch(_HelperPolygon(pts, closed=True, **kwargs))
25
+
26
+ def tick_marks(ax, p1, p2, count=1, size=0.15, gap=0.08, **kwargs):
27
+ p1, p2 = np.array(p1, dtype=float), np.array(p2, dtype=float)
28
+ mid = (p1 + p2) / 2
29
+ along = (p2 - p1) / np.linalg.norm(p2 - p1)
30
+ perp = np.array([-along[1], along[0]])
31
+ kwargs.setdefault("color", "black")
32
+ kwargs.setdefault("linewidth", 1.5)
33
+ for off in (np.arange(count) - (count - 1) / 2) * gap:
34
+ c = mid + along * off
35
+ ax.plot([c[0] - perp[0] * size, c[0] + perp[0] * size],
36
+ [c[1] - perp[1] * size, c[1] + perp[1] * size], **kwargs)
37
+
38
+ def angle_arc(ax, vertex, theta1, theta2, radius=0.5, label=None, label_radius_mult=1.4, **kwargs):
39
+ kwargs.setdefault("color", "black")
40
+ kwargs.setdefault("linewidth", 1.2)
41
+ ax.add_patch(_HelperArc(vertex, 2 * radius, 2 * radius, angle=0, theta1=theta1, theta2=theta2, **kwargs))
42
+ if label:
43
+ mid = np.radians((theta1 + theta2) / 2)
44
+ lx = vertex[0] + radius * label_radius_mult * np.cos(mid)
45
+ ly = vertex[1] + radius * label_radius_mult * np.sin(mid)
46
+ ax.text(lx, ly, label, ha="center", va="center", fontsize=10)
47
+
48
+ def side_label(ax, p1, p2, text=None, offset=None, fmt="{:.2f}", **kwargs):
49
+ p1, p2 = np.array(p1, dtype=float), np.array(p2, dtype=float)
50
+ length = np.linalg.norm(p2 - p1)
51
+ if text is None:
52
+ text = fmt.format(length)
53
+ if offset is None:
54
+ offset = 0.35
55
+ mid = (p1 + p2) / 2
56
+ along = (p2 - p1) / length
57
+ perp = np.array([-along[1], along[0]])
58
+ kwargs.setdefault("ha", "center")
59
+ kwargs.setdefault("va", "center")
60
+ kwargs.setdefault("fontsize", 10)
61
+ ax.text(*(mid + perp * offset), text, **kwargs)
62
+ '''
63
+
64
+
65
+ def autofix_text_overlaps(fig, max_iter: int = 40, min_font_size: float = 6.0) -> None:
66
+ """Detects overlapping ax.text() bounding boxes across every Axes in
67
+ `fig` and nudges colliding labels apart along the line between their
68
+ centers, iterating until none overlap. Each text has a displacement
69
+ budget (proportional to its own size) -- once a text has moved further
70
+ than that without resolving its overlap, repositioning stops for it and
71
+ its font shrinks instead, so a tight cluster that can't be cleanly
72
+ separated ends up smaller rather than flung arbitrarily far from its
73
+ meaningful anchor point. Used directly (in-process) by every
74
+ deterministic renderer below; the model-facing FIGURE sandbox gets the
75
+ equivalent logic via the separately-injected TEXT_OVERLAP_FIX_SRC string
76
+ instead, since that runs in an isolated subprocess that can't import
77
+ this module."""
78
+ fig.canvas.draw()
79
+ for ax in fig.get_axes():
80
+ texts = [t for t in ax.texts if t.get_text().strip()]
81
+ if len(texts) < 2:
82
+ continue
83
+ renderer = fig.canvas.get_renderer()
84
+ budget = [1.3 * max(t.get_window_extent(renderer=renderer).width,
85
+ t.get_window_extent(renderer=renderer).height) for t in texts]
86
+ traveled = [0.0] * len(texts)
87
+ for _ in range(max_iter):
88
+ fig.canvas.draw()
89
+ renderer = fig.canvas.get_renderer()
90
+ bboxes = [t.get_window_extent(renderer=renderer) for t in texts]
91
+ moved = False
92
+ for i in range(len(texts)):
93
+ for j in range(i + 1, len(texts)):
94
+ bi, bj = bboxes[i], bboxes[j]
95
+ if not bi.overlaps(bj):
96
+ continue
97
+ cxi, cyi = bi.x0 + bi.width / 2, bi.y0 + bi.height / 2
98
+ cxj, cyj = bj.x0 + bj.width / 2, bj.y0 + bj.height / 2
99
+ dx, dy = cxj - cxi, cyj - cyi
100
+ dist = (dx ** 2 + dy ** 2) ** 0.5
101
+ if dist < 1e-6:
102
+ dx, dy, dist = float(j - i), 0.0, float(j - i)
103
+ ux, uy = dx / dist, dy / dist
104
+ overlap_x = (bi.width + bj.width) / 2 - abs(dx)
105
+ overlap_y = (bi.height + bj.height) / 2 - abs(dy)
106
+ push = max(overlap_x, overlap_y, 2.0) * 0.5 + 1.5
107
+ for idx, t, sign in ((i, texts[i], -1), (j, texts[j], 1)):
108
+ if traveled[idx] >= budget[idx]:
109
+ if t.get_fontsize() > min_font_size:
110
+ t.set_fontsize(max(min_font_size, t.get_fontsize() * 0.85))
111
+ moved = True
112
+ continue
113
+ trans = t.get_transform()
114
+ x, y = t.get_position()
115
+ px, py = trans.transform((x, y))
116
+ nx, ny = trans.inverted().transform((px + sign * ux * push, py + sign * uy * push))
117
+ t.set_position((nx, ny))
118
+ traveled[idx] += push
119
+ moved = True
120
+ if not moved:
121
+ break
122
+
123
+
124
+ # The model can't be relied on to predict where its own labels will end up
125
+ # relative to each other -- it places each ax.text() independently with no
126
+ # view of the others. This patches plt.savefig (transparently, the model's
127
+ # own `import matplotlib.pyplot as plt` binds the same module object) to
128
+ # detect overlapping text bounding boxes across every Axes right before the
129
+ # real save, and nudge colliding labels apart along the line between their
130
+ # centers, iterating until none overlap. A deterministic, geometric fix --
131
+ # exactly the kind of thing that's calculable rather than left to chance.
132
+ # (Duplicated as source text rather than shared with autofix_text_overlaps()
133
+ # above because this runs inside an isolated model-code subprocess that
134
+ # can't import this module.)
135
+ TEXT_OVERLAP_FIX_SRC = '''
136
+ import matplotlib.pyplot as _overlap_plt
137
+
138
+ def _shift_text_px(text, dx_px, dy_px):
139
+ trans = text.get_transform()
140
+ x, y = text.get_position()
141
+ px, py = trans.transform((x, y))
142
+ nx, ny = trans.inverted().transform((px + dx_px, py + dy_px))
143
+ text.set_position((nx, ny))
144
+
145
+ def _autofix_text_overlaps(fig, max_iter=40, min_font_size=6.0):
146
+ fig.canvas.draw()
147
+ for ax in fig.get_axes():
148
+ texts = [t for t in ax.texts if t.get_text().strip()]
149
+ if len(texts) < 2:
150
+ continue
151
+ renderer = fig.canvas.get_renderer()
152
+ budget = [1.3 * max(t.get_window_extent(renderer=renderer).width,
153
+ t.get_window_extent(renderer=renderer).height) for t in texts]
154
+ traveled = [0.0] * len(texts)
155
+ for _ in range(max_iter):
156
+ fig.canvas.draw()
157
+ renderer = fig.canvas.get_renderer()
158
+ bboxes = [t.get_window_extent(renderer=renderer) for t in texts]
159
+ moved = False
160
+ for i in range(len(texts)):
161
+ for j in range(i + 1, len(texts)):
162
+ bi, bj = bboxes[i], bboxes[j]
163
+ if not bi.overlaps(bj):
164
+ continue
165
+ cxi, cyi = bi.x0 + bi.width / 2, bi.y0 + bi.height / 2
166
+ cxj, cyj = bj.x0 + bj.width / 2, bj.y0 + bj.height / 2
167
+ dx, dy = cxj - cxi, cyj - cyi
168
+ dist = (dx ** 2 + dy ** 2) ** 0.5
169
+ if dist < 1e-6:
170
+ dx, dy, dist = float(j - i), 0.0, float(j - i)
171
+ ux, uy = dx / dist, dy / dist
172
+ overlap_x = (bi.width + bj.width) / 2 - abs(dx)
173
+ overlap_y = (bi.height + bj.height) / 2 - abs(dy)
174
+ push = max(overlap_x, overlap_y, 2.0) * 0.5 + 1.5
175
+ for idx, t, sign in ((i, texts[i], -1), (j, texts[j], 1)):
176
+ if traveled[idx] >= budget[idx]:
177
+ if t.get_fontsize() > min_font_size:
178
+ t.set_fontsize(max(min_font_size, t.get_fontsize() * 0.85))
179
+ moved = True
180
+ continue
181
+ _shift_text_px(t, sign * ux * push, sign * uy * push)
182
+ traveled[idx] += push
183
+ moved = True
184
+ if not moved:
185
+ break
186
+
187
+ _original_savefig = _overlap_plt.savefig
188
+
189
+ def _savefig_with_overlap_fix(*args, **kwargs):
190
+ try:
191
+ _autofix_text_overlaps(_overlap_plt.gcf())
192
+ except Exception:
193
+ pass
194
+ return _original_savefig(*args, **kwargs)
195
+
196
+ _overlap_plt.savefig = _savefig_with_overlap_fix
197
+ '''
simit/skills/pool.py ADDED
@@ -0,0 +1,262 @@
1
+ """Parallel rendering in warm worker processes.
2
+
3
+ Rendering (matplotlib, RDKit, Chromium, executing model-written code) is CPU
4
+ work that would otherwise stall the GPU-driving main process. Each worker is a
5
+ ``python -m simit.skills.pool`` subprocess -- deliberately not a
6
+ multiprocessing child, which would re-import the user's ``__main__`` script --
7
+ that keeps its imports and browser warm. A hung or crashed worker is killed
8
+ and replaced. Skills that cannot be pickled by reference (e.g. defined in a
9
+ notebook or ``__main__``) are rendered in a thread of the main process.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import os
16
+ import pickle
17
+ import queue
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ import time
22
+ import traceback
23
+ from concurrent.futures import Future, ThreadPoolExecutor
24
+ from multiprocessing.connection import Connection
25
+
26
+ from PIL import Image
27
+
28
+ from .base import SpecError
29
+
30
+ RENDER_TIMEOUT_S = 90.0
31
+
32
+
33
+ def _serve(conn_in: Connection, conn_out: Connection):
34
+ os.environ.setdefault("MPLBACKEND", "Agg")
35
+ while True:
36
+ try:
37
+ msg = conn_in.recv()
38
+ except (EOFError, OSError, KeyboardInterrupt):
39
+ return
40
+ if msg is None:
41
+ return
42
+ task_id, payload = msg
43
+ try:
44
+ skill, spec = pickle.loads(payload)
45
+ except Exception as e: # this process cannot import the skill: the parent renders it instead
46
+ conn_out.send((task_id, None, f"{type(e).__name__}: {e}"))
47
+ continue
48
+ try:
49
+ image = skill.render(spec)
50
+ buf = io.BytesIO()
51
+ image.save(buf, format="PNG")
52
+ conn_out.send((task_id, True, buf.getvalue()))
53
+ except SpecError as e:
54
+ conn_out.send((task_id, False, str(e)))
55
+ except Exception as e: # unexpected renderer failure: still reported back for repair
56
+ conn_out.send((task_id, False, f"{type(e).__name__}: {e}"))
57
+ except BaseException:
58
+ conn_out.send((task_id, False, traceback.format_exc()[-500:]))
59
+
60
+
61
+ class _Worker:
62
+ def __init__(self):
63
+ p2c_r, p2c_w = os.pipe()
64
+ c2p_r, c2p_w = os.pipe()
65
+ env = dict(os.environ, MPLBACKEND="Agg", PYTHONUNBUFFERED="1")
66
+ # The parent's import path, so skills from the user's own modules unpickle here too.
67
+ paths = [p for p in sys.path if p and os.path.isdir(p)] + [env.get("PYTHONPATH", "")]
68
+ env["PYTHONPATH"] = os.pathsep.join(p for p in paths if p)
69
+ self.proc = subprocess.Popen(
70
+ [sys.executable, "-m", "simit.skills.pool", str(p2c_r), str(c2p_w)],
71
+ pass_fds=(p2c_r, c2p_w), env=env, stdin=subprocess.DEVNULL,
72
+ )
73
+ os.close(p2c_r)
74
+ os.close(c2p_w)
75
+ self.send_conn = Connection(p2c_w, readable=False)
76
+ self.recv_conn = Connection(c2p_r, writable=False)
77
+ self.task = None # (task_id, future, deadline)
78
+
79
+ def alive(self) -> bool:
80
+ return self.proc.poll() is None
81
+
82
+ def kill(self):
83
+ for conn in (self.send_conn, self.recv_conn):
84
+ try:
85
+ conn.close()
86
+ except Exception:
87
+ pass
88
+ try:
89
+ self.proc.kill()
90
+ self.proc.wait(timeout=5)
91
+ except Exception:
92
+ pass
93
+
94
+
95
+ class RenderPool:
96
+ def __init__(self, num_workers: int | None = None, timeout: float = RENDER_TIMEOUT_S):
97
+ self.num_workers = default_num_workers() if num_workers is None else num_workers
98
+ self.timeout = timeout
99
+ self._queue: "queue.Queue[tuple[int, bytes, Future]]" = queue.Queue()
100
+ self._workers: list[_Worker] = []
101
+ self._lock = threading.Lock()
102
+ self._thread = None
103
+ self._next_id = 0
104
+ self._closed = False
105
+ self._local = ThreadPoolExecutor(max_workers=1, thread_name_prefix="simit-render")
106
+ self._local_types: set = set() # skill classes the workers could not import
107
+
108
+ # ---------------------------------------------------------------- public
109
+ def submit(self, skill, spec) -> Future:
110
+ """Render ``spec`` with ``skill``; the future yields a PIL image or
111
+ raises SpecError."""
112
+ if (self.num_workers <= 0 or not getattr(skill, "render_in_subprocess", True)
113
+ or type(skill).__module__ in ("__main__", "__mp_main__") or _key(skill) in self._local_types):
114
+ return self._local.submit(_render_local, skill, spec)
115
+ try:
116
+ payload = pickle.dumps((skill, spec))
117
+ except Exception:
118
+ return self._local.submit(_render_local, skill, spec)
119
+ fut: Future = Future()
120
+ with self._lock:
121
+ self._next_id += 1
122
+ task_id = self._next_id
123
+ if self._thread is None:
124
+ self._thread = threading.Thread(target=self._loop, daemon=True, name="simit-render-pool")
125
+ self._thread.start()
126
+ self._queue.put((task_id, payload, fut))
127
+ return fut
128
+
129
+ def warmup(self):
130
+ """Start the worker processes now instead of on the first render."""
131
+ with self._lock:
132
+ while len(self._workers) < self.num_workers:
133
+ self._workers.append(_Worker())
134
+
135
+ def close(self):
136
+ self._closed = True
137
+ with self._lock:
138
+ for w in self._workers:
139
+ try:
140
+ w.send_conn.send(None)
141
+ except Exception:
142
+ pass
143
+ w.kill()
144
+ self._workers = []
145
+ self._local.shutdown(wait=False)
146
+
147
+ def __del__(self):
148
+ try:
149
+ self.close()
150
+ except Exception:
151
+ pass
152
+
153
+ # -------------------------------------------------------------- internals
154
+ def _loop(self):
155
+ pending: list[tuple[int, bytes, Future]] = []
156
+ while not self._closed:
157
+ busy = any(w.task is not None for w in self._workers)
158
+ try:
159
+ pending.append(self._queue.get(timeout=0.005 if (pending or busy) else 0.5))
160
+ while True:
161
+ pending.append(self._queue.get_nowait())
162
+ except queue.Empty:
163
+ pass
164
+ with self._lock:
165
+ if self._closed:
166
+ break
167
+ while len(self._workers) < self.num_workers and pending:
168
+ self._workers.append(_Worker())
169
+ workers = list(self._workers)
170
+ for w in workers:
171
+ if w.task is None and pending:
172
+ task_id, payload, fut = pending.pop(0)
173
+ try:
174
+ w.send_conn.send((task_id, payload))
175
+ w.task = (task_id, fut, time.monotonic() + self.timeout, payload)
176
+ except Exception:
177
+ pending.insert(0, (task_id, payload, fut))
178
+ self._replace(w)
179
+ for w in workers:
180
+ if w.task is None:
181
+ continue
182
+ _, fut, deadline, payload = w.task
183
+ try:
184
+ ready = w.recv_conn.poll()
185
+ except Exception:
186
+ ready = False
187
+ if ready:
188
+ try:
189
+ _, ok, data = w.recv_conn.recv()
190
+ except Exception:
191
+ w.task = None
192
+ fut.set_exception(SpecError("renderer process crashed"))
193
+ self._replace(w)
194
+ continue
195
+ w.task = None
196
+ if ok is None:
197
+ skill, spec = pickle.loads(payload)
198
+ self._local_types.add(_key(skill))
199
+ _chain(self._local.submit(_render_local, skill, spec), fut)
200
+ elif ok:
201
+ image = Image.open(io.BytesIO(data))
202
+ image.load()
203
+ fut.set_result(image)
204
+ else:
205
+ fut.set_exception(SpecError(data))
206
+ elif not w.alive():
207
+ w.task = None
208
+ fut.set_exception(SpecError("renderer process crashed"))
209
+ self._replace(w)
210
+ elif time.monotonic() > deadline:
211
+ w.task = None
212
+ fut.set_exception(SpecError(f"rendering timed out after {self.timeout:.0f}s"))
213
+ self._replace(w)
214
+
215
+ def _replace(self, worker: _Worker):
216
+ worker.kill()
217
+ with self._lock:
218
+ if worker in self._workers:
219
+ self._workers[self._workers.index(worker)] = _Worker()
220
+
221
+
222
+ def _key(skill) -> tuple:
223
+ return type(skill).__module__, type(skill).__qualname__
224
+
225
+
226
+ def _chain(src: Future, dst: Future):
227
+ def done(f):
228
+ if f.exception() is not None:
229
+ dst.set_exception(f.exception())
230
+ else:
231
+ dst.set_result(f.result())
232
+ src.add_done_callback(done)
233
+
234
+
235
+ def _render_local(skill, spec):
236
+ try:
237
+ return skill.render(spec)
238
+ except SpecError:
239
+ raise
240
+ except Exception as e:
241
+ raise SpecError(f"{type(e).__name__}: {e}") from None
242
+
243
+
244
+ def default_num_workers() -> int:
245
+ """Worker count from the CPUs actually granted to this job."""
246
+ if os.environ.get("SIMIT_RENDER_WORKERS"):
247
+ return int(os.environ["SIMIT_RENDER_WORKERS"])
248
+ n = None
249
+ for var in ("LSB_DJOB_NUMPROC", "SLURM_CPUS_PER_TASK"):
250
+ if os.environ.get(var, "").isdigit():
251
+ n = int(os.environ[var])
252
+ break
253
+ if n is None:
254
+ try:
255
+ n = len(os.sched_getaffinity(0))
256
+ except AttributeError:
257
+ n = os.cpu_count() or 2
258
+ return max(1, min(8, n - 1 if n > 2 else n))
259
+
260
+
261
+ if __name__ == "__main__":
262
+ _serve(Connection(int(sys.argv[1]), writable=False), Connection(int(sys.argv[2]), readable=False))