hone-python 0.9.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,161 @@
1
+ """Worker processes that evaluate candidates, with an optional time limit on each evaluation.
2
+
3
+ concurrent.futures can't stop an evaluation once it has started, so a hung simulation would
4
+ hold up the run forever. Here each worker is a process of its own: one that runs past the
5
+ time limit, or dies, is replaced, and its evaluation counts as failed.
6
+ """
7
+ import multiprocessing
8
+ import signal
9
+ import time
10
+ from multiprocessing.connection import Connection, wait
11
+ from typing import Any, Callable
12
+
13
+ STOP = None # sent to a worker to make it exit
14
+ READY = "ready" # sent by a worker once it has started and can evaluate
15
+
16
+
17
+ class _Worker:
18
+ def __init__(self, context: Any, evaluate: Callable[[dict], float | None]) -> None:
19
+ self.connection, child = context.Pipe()
20
+ self.process = context.Process(target=_serve, args=(child, evaluate), daemon=True)
21
+ self.process.start()
22
+ child.close()
23
+ self.ready = False
24
+ self.task: Any = None # key of the candidate being evaluated, or None while idle
25
+ self.deadline = float("inf")
26
+
27
+ def start(self, task: Any, params: dict, timeout: float | None) -> None:
28
+ self.connection.send((task, params))
29
+ self.task = task
30
+ self.deadline = time.monotonic() + timeout if timeout is not None else float("inf")
31
+
32
+ def stop(self, force: bool = False) -> None:
33
+ if not force and self.process.is_alive():
34
+ try:
35
+ self.connection.send(STOP)
36
+ self.process.join(timeout=1)
37
+ except (BrokenPipeError, OSError):
38
+ pass
39
+ if self.process.is_alive():
40
+ self.process.kill()
41
+ self.process.join()
42
+ self.connection.close()
43
+
44
+
45
+ def _serve(connection: Connection, evaluate: Callable[[dict], float | None]) -> None:
46
+ """A worker's loop: evaluate each candidate it is sent, and send back the outcome."""
47
+ # Ctrl+C reaches every process in the terminal; the main process handles it and stops the workers.
48
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
49
+ connection.send(READY)
50
+ while (message := connection.recv()) is not STOP:
51
+ task, params = message
52
+ try:
53
+ connection.send((task, evaluate(params), None))
54
+ except Exception as error:
55
+ try:
56
+ connection.send((task, None, error))
57
+ except Exception: # the error itself can't be pickled
58
+ connection.send((task, None, RuntimeError(f"{type(error).__name__}: {error}")))
59
+
60
+
61
+ class EvaluationPool:
62
+ """Evaluates candidates in worker processes, each within an optional time limit in seconds.
63
+
64
+ evaluate(params) runs in the workers; it returns the value, or None for a failed
65
+ evaluation, and any exception it raises stops every worker and is raised again here.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ evaluate: Callable[[dict], float | None],
71
+ workers: int,
72
+ timeout: float | None = None,
73
+ start_method: str | None = None,
74
+ ) -> None:
75
+ self._context = multiprocessing.get_context(start_method)
76
+ self._evaluate = evaluate
77
+ self._timeout = timeout
78
+ self._workers = [self._new_worker() for _ in range(workers)]
79
+ self._queue: list[tuple[Any, dict]] = [] # submitted, not yet started
80
+ self._finished: list[tuple[Any, float | None]] = []
81
+
82
+ def _new_worker(self) -> _Worker:
83
+ return _Worker(self._context, self._evaluate)
84
+
85
+ def map(self, candidates: list[dict]) -> list[float | None]:
86
+ """The outcome of each candidate, in order: its value, or None if it failed, timed out or crashed its worker."""
87
+ outcomes: list[float | None] = [None] * len(candidates)
88
+ for index, params in enumerate(candidates):
89
+ self.submit(index, params)
90
+ while self.pending:
91
+ for index, outcome in self.collect():
92
+ outcomes[index] = outcome
93
+ return outcomes
94
+
95
+ def submit(self, key: Any, params: dict) -> None:
96
+ """Queue a candidate, identified by key, to be evaluated by the next free worker."""
97
+ self._queue.append((key, params))
98
+
99
+ @property
100
+ def pending(self) -> int:
101
+ """Candidates submitted whose outcomes haven't been collected yet."""
102
+ return len(self._queue) + sum(w.task is not None for w in self._workers) + len(self._finished)
103
+
104
+ @property
105
+ def idle(self) -> int:
106
+ """Workers with nothing to do, counting those still starting up."""
107
+ return max(sum(w.task is None for w in self._workers) - len(self._queue), 0)
108
+
109
+ def collect(self) -> list[tuple[Any, float | None]]:
110
+ """Wait until at least one candidate is finished, and return (key, outcome) for each one that is."""
111
+ while not self._finished and (self._queue or any(w.task is not None for w in self._workers)):
112
+ for worker in self._workers:
113
+ if worker.ready and worker.task is None and self._queue:
114
+ key, params = self._queue.pop(0)
115
+ worker.start(key, params, self._timeout)
116
+ # The time limit starts once a worker has started, so it never counts start-up time.
117
+ watched = [w for w in self._workers if w.task is not None or not w.ready]
118
+ deadline = min((w.deadline for w in watched), default=float("inf"))
119
+ limit = None if deadline == float("inf") else max(deadline - time.monotonic(), 0)
120
+ heard = wait([w.connection for w in watched], timeout=limit)
121
+ for worker in watched:
122
+ if worker.connection in heard:
123
+ self._collect(worker)
124
+ elif time.monotonic() >= worker.deadline:
125
+ self._replace(worker) # out of time: its outcome is None
126
+ finished, self._finished = self._finished, []
127
+ return finished
128
+
129
+ def _collect(self, worker: _Worker) -> None:
130
+ try:
131
+ message = worker.connection.recv()
132
+ except (EOFError, OSError):
133
+ if not worker.ready:
134
+ raise RuntimeError("a worker process failed to start; its error is printed above") from None
135
+ self._replace(worker) # the worker died, taking the evaluation with it
136
+ return
137
+ if message == READY:
138
+ worker.ready = True
139
+ return
140
+ task, value, error = message
141
+ if error is not None:
142
+ raise error
143
+ self._finished.append((task, value))
144
+ worker.task = None
145
+
146
+ def _replace(self, worker: _Worker) -> None:
147
+ """Stop worker, counting its evaluation, if any, as failed, and start a new one in its place."""
148
+ if worker.task is not None:
149
+ self._finished.append((worker.task, None))
150
+ worker.stop(force=True)
151
+ self._workers[self._workers.index(worker)] = self._new_worker()
152
+
153
+ def close(self, force: bool = False) -> None:
154
+ for worker in self._workers:
155
+ worker.stop(force)
156
+
157
+ def __enter__(self) -> "EvaluationPool":
158
+ return self
159
+
160
+ def __exit__(self, kind, error, traceback) -> None:
161
+ self.close(force=error is not None)