1import collections.abc as cabc
2import datetime as dt
3import enum
4import logging
5import operator
6import pathlib as plib
7import shutil
8import sys
9import tempfile
10import threading
11import typing as typ
12
13import numpy as np
14
15import pyxu.info.ptype as pxt
16import pyxu.util as pxu
17
18__all__ = [
19 "SolverMode",
20 "Solver",
21 "StoppingCriterion",
22]
23
24
[docs]
25@enum.unique
26class SolverMode(enum.Enum):
27 """
28 Solver execution mode.
29 """
30
31 BLOCK = enum.auto()
32 MANUAL = enum.auto()
33 ASYNC = enum.auto()
34
35
[docs]
36class StoppingCriterion:
37 """
38 State machines (SM) which decide when to stop iterative solvers by examining their mathematical state.
39
40 SM decisions are always accompanied by at least one numerical statistic. These stats may be queried by solvers via
41 :py:meth:`~pyxu.abc.StoppingCriterion.info` to provide diagnostic information to users.
42
43 Composite stopping criteria can be implemented via the overloaded (and[``&``], or[``|``]) operators.
44 """
45
[docs]
46 def stop(self, state: cabc.Mapping[str]) -> bool:
47 """
48 Compute a stop signal based on the current mathematical state.
49
50 Parameters
51 ----------
52 state: ~collections.abc.Mapping
53 Full mathematical state of solver at some iteration, i.e. :py:attr:`~pyxu.abc.Solver._mstate`.
54
55 Values from `state` may be cached inside the instance to form complex stopping conditions.
56
57 Returns
58 -------
59 s: bool
60 True if no further iterations should be performed, False otherwise.
61 """
62 raise NotImplementedError
63
[docs]
64 def info(self) -> cabc.Mapping[str, float]:
65 """
66 Get statistics associated with the last call to :py:meth:`~pyxu.abc.StoppingCriterion.stop`.
67
68 Returns
69 -------
70 data: ~collections.abc.Mapping
71 """
72 raise NotImplementedError
73
[docs]
74 def clear(self):
75 """
76 Clear SM state (if any).
77
78 This method is useful when a :py:class:`~pyxu.abc.StoppingCriterion` instance must be reused in another call to
79 :py:meth:`~pyxu.abc.Solver.fit`.
80 """
81 pass
82
83 def __or__(self, other: "StoppingCriterion") -> "StoppingCriterion":
84 return _StoppingCriteriaComposition(lhs=self, rhs=other, op=operator.or_)
85
86 def __and__(self, other: "StoppingCriterion") -> "StoppingCriterion":
87 return _StoppingCriteriaComposition(lhs=self, rhs=other, op=operator.and_)
88
89
90class _StoppingCriteriaComposition(StoppingCriterion):
91 def __init__(
92 self,
93 lhs: "StoppingCriterion",
94 rhs: "StoppingCriterion",
95 op: cabc.Callable[[bool, bool], bool],
96 ):
97 self._lhs = lhs
98 self._rhs = rhs
99 self._op = op
100
101 def stop(self, state: cabc.Mapping) -> bool:
102 return self._op(self._lhs.stop(state), self._rhs.stop(state))
103
104 def info(self) -> cabc.Mapping[str, float]:
105 return {**self._lhs.info(), **self._rhs.info()}
106
107 def clear(self):
108 self._lhs.clear()
109 self._rhs.clear()
110
111
[docs]
112class Solver:
113 r"""
114 Iterative solver for minimization problems of the form :math:`\hat{x} = \arg\min_{x \in \mathbb{R}^{M_{1}
115 \times\cdots\times M_{D}}} \mathcal{F}(x)`, where the form of :math:`\mathcal{F}` is solver-dependent.
116
117 Solver provides a versatile API for solving optimisation problems, with the following features:
118
119 * manual/automatic/background execution of solver iterations via parameters provided to
120 :py:meth:`~pyxu.abc.Solver.fit`. (See below.)
121 * automatic checkpointing of solver progress, providing a safe restore point in case of faulty numerical code. Each
122 solver instance backs its state and final output to a folder on disk for post-analysis. In particular
123 :py:meth:`~pyxu.abc.Solver.fit` should never crash: detailed exception information will always be available in a
124 logfile for post-analysis.
125 * arbitrary specification of complex stopping criteria via the :py:class:`~pyxu.abc.StoppingCriterion` class.
126 * solve for multiple initial points in parallel. (Not always supported by all solvers.)
127
128 To implement a new iterative solver, users need to sub-class :py:class:`~pyxu.abc.Solver` and overwrite the methods
129 below:
130
131 * :py:meth:`~pyxu.abc.Solver.__init__`
132 * :py:meth:`~pyxu.abc.Solver.m_init` [i.e. math-init()]
133 * :py:meth:`~pyxu.abc.Solver.m_step` [i.e. math-step()]
134 * :py:meth:`~pyxu.abc.Solver.default_stop_crit` [optional; see method definition for details]
135 * :py:meth:`~pyxu.abc.Solver.objective_func` [optional; see method definition for details]
136
137 Advanced functionalities of :py:class:`~pyxu.abc.Solver` are automatically inherited by sub-classes.
138
139
140 Examples
141 --------
142 Here are examples on how to solve minimization problems with this class:
143
144 .. code-block:: python3
145
146 slvr = Solver()
147
148 ### 1. Blocking mode: .fit() does not return until solver has stopped.
149 >>> slvr.fit(mode=SolverMode.BLOCK, ...)
150 >>> data, hist = slvr.stats() # final output of solver.
151
152 ### 2. Async mode: solver iterations run in the background.
153 >>> slvr.fit(mode=SolverMode.ASYNC, ...)
154 >>> print('test') # you can do something in between.
155 >>> slvr.busy() # or check whether the solver already stopped.
156 >>> slvr.stop() # and preemptively force it to stop.
157 >>> data, hist = slvr.stats() # then query the result after a (potential) force-stop.
158
159 ### 3. Manual mode: fine-grain control of solver data per iteration.
160 >>> slvr.fit(mode=SolverMode.MANUAL, ...)
161 >>> for data in slvr.steps():
162 ... # Do something with the logged variables after each iteration.
163 ... pass # solver has stopped after the loop.
164 >>> data, hist = slvr.stats() # final output of solver.
165 """
166
167 _mstate: dict[str, typ.Any] #: Mathematical state.
168 _astate: dict[str, typ.Any] #: Book-keeping (non-math) state.
169
[docs]
170 def __init__(
171 self,
172 *,
173 folder: pxt.Path = None,
174 exist_ok: bool = False,
175 stop_rate: pxt.Integer = 1,
176 writeback_rate: pxt.Integer = None,
177 verbosity: pxt.Integer = None,
178 show_progress: bool = True,
179 log_var: pxt.VarName = frozenset(),
180 ):
181 """
182 Parameters
183 ----------
184 folder: Path
185 Directory on disk where instance data should be stored. A location will be automatically chosen if
186 unspecified. (Default: OS-dependent tempdir.)
187 exist_ok: bool
188 If `folder` is specified and `exist_ok` is false (default), :py:class:`FileExistsError` is raised if the
189 target directory already exists.
190 stop_rate: Integer
191 Rate at which solver evaluates stopping criteria.
192 writeback_rate: Integer
193 Rate at which solver checkpoints are written to disk:
194
195 - If `None` (default), all checkpoints are disabled: the final solver output is only stored in memory.
196 - If `0`, intermediate checkpoints are disabled: only the final solver output will be written to disk.
197 - Any other integer: checkpoint to disk at provided interval. Must be a multiple of `stop_rate`.
198 verbosity: Integer
199 Rate at which stopping criteria statistics are logged. Must be a multiple of `stop_rate`. Defaults to
200 `stop_rate` if unspecified.
201 show_progress: bool
202 If True (default) and :py:meth:`~pyxu.abc.Solver.fit` is run with mode=BLOCK, then statistics are also
203 logged to stdout.
204 log_var: VarName
205 Variables from the solver's math-state (:py:attr:`~pyxu.abc.Solver._mstate`) to be logged per iteration.
206 These are the variables made available when calling :py:meth:`~pyxu.abc.Solver.stats`.
207
208 Notes
209 -----
210 * Partial device<>CPU synchronization takes place when stopping-criteria are evaluated. Increasing `stop_rate`
211 is advised to reduce the effect of such transfers when applicable.
212 * Full device<>CPU synchronization takes place at checkpoint-time. Increasing `writeback_rate` is advised to
213 reduce the effect of such transfers when applicable.
214 """
215 self._mstate = dict()
216 self._astate = dict(
217 history=None, # stopping criteria values per iteration
218 idx=0, # iteration index
219 log_rate=None,
220 log_var=None,
221 logger=None,
222 stdout=None,
223 stop_crit=None,
224 stop_rate=None,
225 track_objective=None,
226 wb_rate=None,
227 workdir=None,
228 # Execution-mode related -----------
229 mode=None,
230 active=None,
231 worker=None,
232 )
233
234 try:
235 if folder is None:
236 folder = plib.Path(tempfile.mkdtemp(prefix="pyxu_"))
237 elif (folder := plib.Path(folder).expanduser().resolve()).exists() and (not exist_ok):
238 raise FileExistsError(f"{folder} already exists.")
239 else:
240 shutil.rmtree(folder, ignore_errors=True)
241 folder.mkdir(parents=True)
242 self._astate["workdir"] = folder
243 except Exception:
244 raise Exception(f"folder: expected path-like, got {type(folder)}.")
245
246 try:
247 assert stop_rate >= 1
248 self._astate["stop_rate"] = int(stop_rate)
249 except Exception:
250 raise ValueError(f"stop_rate must be positive, got {stop_rate}.")
251
252 try:
253 if writeback_rate is None: # no checkpoints
254 pass
255 elif writeback_rate == 0: # final checkpoint only
256 pass
257 else: # regular checkpoints
258 assert writeback_rate > 0
259 assert writeback_rate % self._astate["stop_rate"] == 0
260 writeback_rate = int(writeback_rate)
261 self._astate["wb_rate"] = writeback_rate
262 except Exception:
263 raise ValueError(f"writeback_rate must be (None, 0, <multiple of stop_rate>), got {writeback_rate}.")
264
265 try:
266 if verbosity is None:
267 verbosity = self._astate["stop_rate"]
268 assert verbosity % self._astate["stop_rate"] == 0
269 self._astate["log_rate"] = int(verbosity)
270 self._astate["stdout"] = bool(show_progress)
271 except Exception:
272 raise ValueError(f"verbosity must be a multiple of stop_rate({stop_rate}), got {verbosity}.")
273
274 try:
275 if isinstance(log_var, str):
276 log_var = (log_var,)
277 self._astate["log_var"] = frozenset(log_var)
278 except Exception:
279 raise ValueError(f"log_var: expected collection, got {type(log_var)}.")
280
[docs]
281 def fit(self, **kwargs):
282 r"""
283 Solve minimization problem(s) defined in :py:meth:`~pyxu.abc.Solver.__init__`, with the provided run-specifc
284 parameters.
285
286 Parameters
287 ----------
288 kwargs
289 See class-level docstring for class-specific keyword parameters.
290 stop_crit: StoppingCriterion
291 Stopping criterion to end solver iterations. If unspecified, defaults to
292 :py:meth:`~pyxu.abc.Solver.default_stop_crit`.
293 mode: SolverMode
294 Execution mode.
295 See :py:class:`~pyxu.abc.Solver` for usage examples.
296
297 Useful method pairs depending on the execution mode:
298
299 * BLOCK: :py:meth:`~pyxu.abc.Solver.fit`
300 * ASYNC: :py:meth:`~pyxu.abc.Solver.fit`, :py:meth:`~pyxu.abc.Solver.busy`, :py:meth:`~pyxu.abc.Solver.stop`
301 * MANUAL: :py:meth:`~pyxu.abc.Solver.fit`, :py:meth:`~pyxu.abc.Solver.steps`
302 track_objective: bool
303 Auto-compute objective function every time stopping criterion is evaluated.
304 """
305 self._fit_init(
306 mode=kwargs.pop("mode", SolverMode.BLOCK),
307 stop_crit=kwargs.pop("stop_crit", None),
308 track_objective=kwargs.pop("track_objective", False),
309 )
310 self.m_init(**kwargs)
311 self._fit_run()
312
[docs]
313 def m_init(self, **kwargs):
314 """
315 Set solver's initial mathematical state based on kwargs provided to :py:meth:`~pyxu.abc.Solver.fit`.
316
317 This method must only manipulate :py:attr:`~pyxu.abc.Solver._mstate`.
318
319 After calling this method, the solver must be able to complete its 1st iteration via a call to
320 :py:meth:`~pyxu.abc.Solver.m_step`.
321 """
322 raise NotImplementedError
323
[docs]
324 def m_step(self):
325 """
326 Perform one (mathematical) step.
327
328 This method must only manipulate :py:attr:`~pyxu.abc.Solver._mstate`.
329 """
330 raise NotImplementedError
331
[docs]
332 def steps(self, n: pxt.Integer = None) -> cabc.Generator:
333 """
334 Generator of logged variables after each iteration.
335
336 The i-th call to :py:func:`next` on this object returns the logged variables after the i-th solver iteration.
337
338 This method is only usable after calling :py:meth:`~pyxu.abc.Solver.fit` with mode=MANUAL. See
339 :py:class:`~pyxu.abc.Solver` for usage examples.
340
341 There is no guarantee that a checkpoint on disk exists when the generator is exhausted. (Reason: potential
342 exceptions raised during solver's progress.) Users should invoke :py:meth:`~pyxu.abc.Solver.writeback`
343 afterwards if needed.
344
345 Parameters
346 ----------
347 n: Integer
348 Maximum number of :py:func:`next` calls allowed before exhausting the generator. Defaults to infinity if
349 unspecified.
350
351 The generator will terminate prematurely if the solver naturally stops before `n` calls to :py:func:`next`
352 are made.
353 """
354 self._check_mode(SolverMode.MANUAL)
355 i = 0
356 while (n is None) or (i < n):
357 if self._step():
358 data, _ = self.stats()
359 yield data
360 i += 1
361 else:
362 self._astate["mode"] = None # force steps() to be call-once when exhausted.
363 self._cleanup_logger()
364 return
365
366 _stats_data_spec = dict[str, typ.Union[pxt.Real, pxt.NDArray, None]]
367 _stats_history_spec = typ.Union[np.ndarray, None]
368
[docs]
369 def stats(self) -> tuple[_stats_data_spec, _stats_history_spec]:
370 """
371 Query solver state.
372
373 Returns
374 -------
375 data: ~collections.abc.Mapping
376 Value(s) of ``log_var`` (s) after last iteration.
377 history: numpy.ndarray, None
378 (N_iter,) records of stopping-criteria values sampled every `stop_rate` iteration.
379
380 Notes
381 -----
382 If any of the ``log_var`` (s) and/or ``history`` are not (yet) known at query time, ``None`` is returned.
383 """
384 history = self._astate["history"]
385 if history is not None:
386 if len(history) > 0:
387 history = np.concatenate(history, dtype=history[0].dtype, axis=0)
388 else:
389 history = None
390 data = {k: self._mstate.get(k) for k in self._astate["log_var"]}
391 return data, history
392
393 @property
394 def workdir(self) -> pxt.Path:
395 """
396 Returns
397 -------
398 wd: Path
399 Absolute path to the directory on disk where instance data is stored.
400 """
401 return self._astate["workdir"]
402
403 @property
404 def logfile(self) -> pxt.Path:
405 """
406 Returns
407 -------
408 lf: Path
409 Absolute path to the log file on disk where stopping criteria statistics are logged.
410 """
411 return self.workdir / "solver.log"
412
413 @property
414 def datafile(self) -> pxt.Path:
415 """
416 Returns
417 -------
418 df: Path
419 Absolute path to the file on disk where ``log_var`` (s) are stored during checkpointing or after solver has
420 stopped.
421 """
422 return self.workdir / "data.zarr"
423
[docs]
424 def busy(self) -> bool:
425 """
426 Test if an async-running solver has stopped.
427
428 This method is only usable after calling :py:meth:`~pyxu.abc.Solver.fit` with mode=ASYNC. See
429 :py:class:`~pyxu.abc.Solver` for usage examples.
430
431 Returns
432 -------
433 b: bool
434 True if solver has stopped, False otherwise.
435 """
436 self._check_mode(SolverMode.ASYNC, SolverMode.BLOCK)
437 return self._astate["active"].is_set()
438
[docs]
439 def solution(self):
440 """
441 Output the "solution" of the optimization problem.
442
443 This is a helper method intended for novice users. The return type is sub-class dependent, so don't write an
444 API using this: use :py:meth:`~pyxu.abc.Solver.stats` instead.
445 """
446 raise NotImplementedError
447
[docs]
448 def stop(self):
449 """
450 Stop an async-running solver.
451
452 This method is only usable after calling :py:meth:`~pyxu.abc.Solver.fit` with mode=ASYNC. See
453 :py:class:`~pyxu.abc.Solver` for usage examples.
454
455 This method will block until the solver has stopped.
456
457 There is no guarantee that a checkpoint on disk exists once halted. (Reason: potential exceptions raised during
458 solver's progress.) Users should invoke :py:meth:`~pyxu.abc.Solver.writeback` afterwards if needed.
459
460 Users must call this method to terminate an async-solver, even if :py:meth:`~pyxu.abc.Solver.busy` is False.
461 """
462 self._check_mode(SolverMode.ASYNC, SolverMode.BLOCK)
463 self._astate["active"].clear()
464 self._astate["worker"].join()
465 self._astate.update(
466 mode=None, # forces stop() to be call-once.
467 active=None,
468 worker=None,
469 )
470 self._cleanup_logger()
471
472 def _fit_init(
473 self,
474 mode: SolverMode,
475 stop_crit: StoppingCriterion,
476 track_objective: bool,
477 ):
478 def _init_logger():
479 log_name = str(self.workdir)
480 logger = logging.getLogger(log_name)
481 logger.handlers.clear()
482 logger.setLevel("DEBUG")
483
484 fmt = logging.Formatter(fmt="{levelname} -- {message}", style="{")
485 handler = [logging.FileHandler(self.logfile, mode="w")]
486 if (mode is SolverMode.BLOCK) and self._astate["stdout"]:
487 handler.append(logging.StreamHandler(sys.stdout))
488 for h in handler:
489 h.setLevel("DEBUG")
490 h.setFormatter(fmt)
491 logger.addHandler(h)
492
493 return logger
494
495 self._mstate.clear()
496
497 if stop_crit is None:
498 stop_crit = self.default_stop_crit()
499 stop_crit.clear()
500
501 if track_objective:
502 from pyxu.opt.stop import Memorize
503
504 stop_crit |= Memorize(var="objective_func")
505
506 self._astate.update( # suitable state for a new call to fit().
507 history=[],
508 idx=0,
509 logger=_init_logger(),
510 stop_crit=stop_crit,
511 track_objective=track_objective,
512 mode=mode,
513 active=None,
514 worker=None,
515 )
516
517 def _fit_run(self):
518 self._m_persist()
519
520 mode = self._astate["mode"]
521 if mode is SolverMode.MANUAL:
522 # User controls execution via steps().
523 pass
524 else: # BLOCK / ASYNC
525 self._astate.update(
526 active=threading.Event(),
527 worker=Solver._Worker(self),
528 )
529 self._astate["active"].set()
530 self._astate["worker"].start()
531 if mode is SolverMode.BLOCK:
532 self._astate["worker"].join()
533 self.stop() # state clean-up
534 else:
535 # User controls execution via busy() + stop().
536 pass
537
[docs]
538 def writeback(self):
539 """
540 Checkpoint state to disk.
541 """
542 data, history = self.stats()
543
544 pxu.save_zarr(self.datafile, {"history": history, **data})
545
546 def _check_mode(self, *modes: SolverMode):
547 m = self._astate["mode"]
548 if m in modes:
549 pass # ok
550 else:
551 if m is None:
552 msg = "Illegal method call: invoke Solver.fit() first."
553 else:
554 msg = " ".join(
555 [
556 "Illegal method call: can only be used if Solver.fit() invoked with",
557 "mode=Any[" + ", ".join(map(lambda _: str(_.name), modes)) + "]",
558 ]
559 )
560 raise ValueError(msg)
561
562 def _step(self) -> bool:
563 ast = self._astate # shorthand
564
565 must_stop = lambda: ast["idx"] % ast["stop_rate"] == 0
566 must_log = lambda: ast["idx"] % ast["log_rate"] == 0
567 checkpoint_enabled = ast["wb_rate"] not in (None, 0) # performing regular checkpoints?
568 must_writeback = lambda: checkpoint_enabled and (ast["idx"] % ast["wb_rate"] == 0)
569
570 def _log(msg: str = None):
571 if msg is None: # report stopping-criterion values
572 h = ast["history"][-1][0]
573 msg = [f"[{dt.datetime.now()}] Iteration {ast['idx']:>_d}"]
574 for field, value in zip(h.dtype.names, h):
575 msg.append(f"\t{field}: {value}")
576 msg = "\n".join(msg)
577 ast["logger"].info(msg)
578
579 def _update_history():
580 def _as_struct(data: dict[str, float]) -> np.ndarray:
581 ftype = type(x) if isinstance(x := next(iter(data.values())), (int, float)) else x.dtype
582
583 spec_data = [(k, ftype) for k in data]
584
585 itype = np.int64
586 spec_iter = [("iteration", itype)]
587
588 dtype = np.dtype(spec_iter + spec_data)
589
590 utype = np.uint8
591 s = np.concatenate( # to allow mixed int/float fields:
592 [ # (1) cast to uint, then (2) to compound dtype.
593 np.array([ast["idx"]], dtype=itype).view(utype),
594 np.array(list(data.values()), dtype=ftype).view(utype),
595 ]
596 ).view(dtype)
597 return s
598
599 h = _as_struct(ast["stop_crit"].info())
600 ast["history"].append(h)
601
602 # [Sepand] Important
603 # stop_crit.stop(), _update_history(), _log() must always be called in this order.
604
605 try:
606 _ms, _ml, _mw = must_stop(), must_log(), must_writeback()
607
608 if _ms and ast["track_objective"]:
609 self._mstate["objective_func"] = self.objective_func().reshape(-1)
610
611 if _ms and ast["stop_crit"].stop(self._mstate):
612 _update_history()
613 _log()
614 _log(msg=f"[{dt.datetime.now()}] Stopping Criterion satisfied -> END")
615 if ast["wb_rate"] is not None:
616 self.writeback()
617 return False
618 else:
619 if _ms:
620 _update_history()
621 if _ml:
622 _log()
623 if _mw:
624 self.writeback()
625 ast["idx"] += 1
626 self.m_step()
627 if _ms:
628 self._m_persist()
629 return True
630 except Exception as e:
631 msg = f"[{dt.datetime.now()}] Something went wrong -> EXCEPTION RAISED"
632 msg_xtra = f"More information: {self.logfile}."
633 print("\n".join([msg, msg_xtra]), file=sys.stderr)
634 if checkpoint_enabled: # checkpointing enabled
635 _, r = divmod(ast["idx"], ast["wb_rate"])
636 idx_valid = ast["idx"] - r
637 msg_idx = f"Last valid checkpoint done at iteration={idx_valid}."
638 msg = "\n".join([msg, msg_idx])
639 ast["logger"].exception(msg, exc_info=e)
640 return False
641
642 def _m_persist(self):
643 # Persist math state to avoid re-eval overhead.
644 k, v = zip(*self._mstate.items())
645 v = pxu.compute(*v, mode="persist", traverse=False)
646 self._mstate.update(zip(k, v))
647 # [Sepand] Note:
648 # The above evaluation strategy with `traverse=False` chosen since _mstate can hold any type
649 # of object.
650
651 def _cleanup_logger(self):
652 # Close file-handlers
653 log_name = str(self.workdir)
654 logger = logging.getLogger(log_name)
655 for handler in logger.handlers:
656 handler.close()
657
[docs]
658 def default_stop_crit(self) -> StoppingCriterion:
659 """
660 Default stopping criterion for solver if unspecified in :py:meth:`~pyxu.abc.Solver.fit` calls.
661
662 Sub-classes are expected to overwrite this method. If not overridden, then omitting the `stop_crit` parameter
663 in :py:meth:`~pyxu.abc.Solver.fit` is forbidden.
664 """
665 raise NotImplementedError("No default stopping criterion defined.")
666
[docs]
667 def objective_func(self) -> pxt.NDArray:
668 """
669 Evaluate objective function given current math state.
670
671 The output array must have shape:
672
673 * (1,) if evaluated at 1 point,
674 * (N, 1) if evaluated at N different points.
675
676 Sub-classes are expected to overwrite this method. If not overridden, then enabling `track_objective` in
677 :py:meth:`~pyxu.abc.Solver.fit` is forbidden.
678 """
679 raise NotImplementedError("No objective function defined.")
680
681 class _Worker(threading.Thread):
682 def __init__(self, solver: "Solver"):
683 super().__init__()
684 self.slvr = solver
685
686 def run(self):
687 while self.slvr.busy() and self.slvr._step():
688 pass
689 self.slvr._astate["active"].clear()