solver-support 0.1.1__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,702 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Robust subprocess runner for solver execution with proper error handling,
4
+ signal management, logging capabilities, and structured summary generation.
5
+ """
6
+
7
+ import logging
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional, List, Dict
13
+ import psutil
14
+ import os
15
+ import re
16
+
17
+ from .run_summary import RunSummary, PhaseResult, create_run_summary
18
+ from .file_management import SolutionFileNamer
19
+ from .solver_stats import SolverStatsExtractor, detect_solver_from_command
20
+ from .utilities import setup_logger, relativize_path
21
+ from .global_state import global_state
22
+
23
+
24
+ class SolverExecutableError(RuntimeError):
25
+ """Raised when a solver executable cannot be launched (missing / not
26
+ executable). Signals the horizon scan to abort: the binary will not appear
27
+ on a later horizon, so retrying every horizon to the cap is pointless."""
28
+
29
+
30
+ class SolverResult:
31
+ """Encapsulates the result of a solver execution."""
32
+
33
+ def __init__(self,
34
+ command: List[str],
35
+ returncode: int,
36
+ stdout: str,
37
+ stderr: str,
38
+ duration: float,
39
+ timed_out: bool = False,
40
+ interrupted: bool = False,
41
+ best_plan_number: int = 0,
42
+ saved_solutions: dict = None,
43
+ launch_failed: bool = False):
44
+ self.command = command
45
+ self.returncode = returncode
46
+ self.stdout = stdout
47
+ self.stderr = stderr
48
+ self.duration = duration
49
+ self.timed_out = timed_out
50
+ self.interrupted = interrupted
51
+ # True when the executable itself could not be started (missing binary /
52
+ # not executable). Distinct from interrupted: a launch failure is an
53
+ # infrastructure error that will not resolve on a later horizon, so the
54
+ # horizon scan should abort rather than retry. See docs/server-harness.md.
55
+ self.launch_failed = launch_failed
56
+ self.best_plan_number = best_plan_number # For anytime portfolio interrupt handling
57
+ self.saved_solutions = saved_solutions or {} # Map plan_num -> saved_file_path
58
+
59
+ @property
60
+ def success(self) -> bool:
61
+ """True if the command completed successfully."""
62
+ return self.returncode == 0 and not self.timed_out and not self.interrupted
63
+
64
+ @property
65
+ def failed(self) -> bool:
66
+ """True if the command failed (inverse of success)."""
67
+ return not self.success
68
+
69
+ def __str__(self) -> str:
70
+ status = "SUCCESS" if self.success else "FAILED"
71
+ if self.timed_out:
72
+ status = "TIMEOUT"
73
+ elif self.interrupted:
74
+ status = "INTERRUPTED"
75
+
76
+ return f"SolverResult({' '.join(self.command)}) -> {status} (rc={self.returncode}, t={self.duration:.2f}s)"
77
+
78
+
79
+ class SolverRunner:
80
+ """
81
+ Robust subprocess runner with timeout, signal handling, comprehensive logging,
82
+ and structured summary generation.
83
+
84
+ Designed to replace the basic doit() function in the run script with better
85
+ error handling, process management, logging capabilities, and optional
86
+ phase-based execution tracking with automatic statistics extraction.
87
+ """
88
+
89
+ def __init__(self,
90
+ log_file: Optional[Path] = None,
91
+ timeout: Optional[int] = None,
92
+ summary_file: Optional[Path] = None,
93
+ instance: str = "",
94
+ model: str = "",
95
+ solver: str = "",
96
+ timestamp: str = None,
97
+ output_dir: Optional[Path] = None,
98
+ log_level: Optional[int] = None):
99
+ self.log_file = log_file
100
+ self.timeout = timeout
101
+ # Console verbosity for this runner's logger. ``global_state`` can only
102
+ # ever *raise* verbosity (its log_level() floors at WARNING), so a
103
+ # consumer that wants a quieter runner - e.g. a benchmark harness where
104
+ # a timed-out row is an ordinary result - passes an explicit level here.
105
+ # None keeps the global behaviour. The file handler always captures
106
+ # everything regardless.
107
+ self.log_level = log_level
108
+ self.logger = self._setup_logger()
109
+ self.summary_file = summary_file
110
+ # Output directory should already exist and we should already be in it
111
+ self.output_dir = output_dir or Path.cwd()
112
+
113
+ # Track whether we created the output directory for cleanup purposes
114
+ self.created_output_dir = False # the higher-level runner handles directory creation
115
+
116
+ # Create summary with directory information
117
+ self.summary = create_run_summary(
118
+ instance, model, solver, timestamp,
119
+ output_dir=str(self.output_dir)
120
+ )
121
+ self._current_phase: Optional[PhaseResult] = None
122
+
123
+ def _setup_logger(self) -> logging.Logger:
124
+ """Set up logging to both console and file if specified."""
125
+ level = self.log_level if self.log_level is not None else global_state.log_level()
126
+ return setup_logger(f'solver_runner_{id(self)}', log_file=self.log_file, log_level=level)
127
+
128
+ def cleanup_output_dir(self) -> None:
129
+ """Clean up output directory if it's empty and we created it."""
130
+ try:
131
+ if self.created_output_dir and self.output_dir.exists():
132
+ # Check if directory is empty (ignoring hidden files)
133
+ files = list(self.output_dir.iterdir())
134
+ if not files:
135
+ self.output_dir.rmdir()
136
+ self.logger.debug(f"Removed empty output directory: {self.output_dir}")
137
+ except Exception as e:
138
+ self.logger.warning(f"Failed to clean up output directory: {e}")
139
+
140
+ def _terminate_process_tree(self, process: subprocess.Popen) -> None:
141
+ """Terminate a process and all its children."""
142
+ try:
143
+ parent_pid = process.pid
144
+ parent = psutil.Process(parent_pid)
145
+ children = parent.children(recursive=True)
146
+
147
+ # Terminate children first
148
+ for child in children:
149
+ try:
150
+ self.logger.debug(f"Terminating child process {child.pid}")
151
+ child.terminate()
152
+ except psutil.NoSuchProcess:
153
+ pass
154
+
155
+ # Terminate parent
156
+ try:
157
+ self.logger.debug(f"Terminating parent process {parent_pid}")
158
+ parent.terminate()
159
+ except psutil.NoSuchProcess:
160
+ pass
161
+
162
+ # Wait for graceful termination
163
+ gone, still_alive = psutil.wait_procs(children + [parent], timeout=3)
164
+
165
+ # Kill any remaining processes
166
+ for proc in still_alive:
167
+ try:
168
+ self.logger.debug(f"Force killing process {proc.pid}")
169
+ proc.kill()
170
+ except psutil.NoSuchProcess:
171
+ pass
172
+
173
+ except psutil.NoSuchProcess:
174
+ # Process already terminated
175
+ pass
176
+ except Exception as e:
177
+ self.logger.error(f"Error terminating process tree: {e}")
178
+
179
+ def run(self,
180
+ command: List[str],
181
+ timeout: Optional[int] = None,
182
+ env: Optional[Dict[str, str]] = None,
183
+ phase_name: Optional[str] = None,
184
+ stream_output: bool = True,
185
+ cwd: Optional[Path] = None) -> SolverResult:
186
+ """
187
+ Run a command with robust error handling and logging.
188
+
189
+ Args:
190
+ command: Command and arguments to run
191
+ timeout: Timeout in seconds (uses instance timeout if None)
192
+ env: Environment variables for the command
193
+ phase_name: "translation", "solving", "visualisation"
194
+ cwd: Working directory for the subprocess. None (the default) keeps
195
+ the caller's own working directory, which is what the solver
196
+ classes rely on - they are documented to run after a chdir and
197
+ to use paths relative to it. A consumer that would rather not
198
+ chdir a whole process can instead point each subprocess here.
199
+
200
+ Returns:
201
+ SolverResult containing execution details
202
+ """
203
+ if timeout is None:
204
+ timeout = self.timeout
205
+
206
+ # Prepare the command
207
+ full_command = command.copy()
208
+
209
+ self.logger.info(f"Executing: {' '.join(full_command)}")
210
+ if timeout:
211
+ self.logger.info(f"Timeout: {timeout}s")
212
+
213
+ start_time = time.time()
214
+ process = None
215
+ timed_out = False
216
+ interrupted = False
217
+ best_plan_number = 0
218
+ saved_solutions = {}
219
+
220
+ try:
221
+ # Set up environment
222
+ run_env = os.environ.copy()
223
+ if env:
224
+ run_env.update(env)
225
+
226
+ # Start the process
227
+ process = subprocess.Popen(
228
+ full_command,
229
+ stdout=subprocess.PIPE,
230
+ stderr=subprocess.PIPE,
231
+ text=True,
232
+ cwd=str(cwd) if cwd is not None else None,
233
+ env=run_env,
234
+ preexec_fn=os.setsid if hasattr(os, 'setsid') else None
235
+ )
236
+
237
+ if stream_output:
238
+ # Use threading to read stdout/stderr like `tee` - both print and capture
239
+ import threading
240
+
241
+ stdout_lines = []
242
+ stderr_lines = []
243
+
244
+ # Track anytime portfolio solutions in real-time
245
+ best_plan_number = 0
246
+ saved_solutions = {} # Map plan_num -> saved_file_path
247
+
248
+ def read_stdout():
249
+ nonlocal best_plan_number, saved_solutions
250
+ try:
251
+ while True:
252
+ # Check for interrupt before reading
253
+ if global_state.should_force_termination:
254
+ break
255
+ line = process.stdout.readline()
256
+ if not line: # EOF
257
+ break
258
+
259
+ # process the line for solution detection first
260
+ stdout_lines.append(line)
261
+
262
+ # Track portfolio plan numbers for interrupt handling
263
+ if "plan manager: found new plan with cost" in line:
264
+ # a plan was found and registered
265
+ best_plan_number += 1
266
+
267
+ # save completed plan file to results
268
+ current_plan_file = Path(f"sas_plan.{best_plan_number}")
269
+ if current_plan_file.exists() and best_plan_number not in saved_solutions and self.output_dir:
270
+ try:
271
+ timestamp = self.summary.timestamp
272
+ instance_name = self.summary.instance
273
+ # Throwaway streaming copy, distinct from the final
274
+ # SolutionFileNamer path (the `.partial` infix) so
275
+ # _handle_anytime_solutions can copy then unlink it.
276
+ # Built through SolutionFileNamer (with the tune slot)
277
+ # rather than a hardcoded "-fd-pddlsoup-" so symk soup
278
+ # runs name their partials correctly and the slot stays
279
+ # in sync with the final solution file.
280
+ solution_file = self.output_dir / SolutionFileNamer.generate_solution_filename(
281
+ instance_name, self.summary.solver,
282
+ self.summary.model, timestamp,
283
+ tune=self.summary.tune_slot,
284
+ suffix=f".{best_plan_number}.partial.solution"
285
+ )
286
+
287
+ import shutil
288
+ shutil.copy2(str(current_plan_file), str(solution_file))
289
+ saved_solutions[best_plan_number] = solution_file
290
+
291
+ except Exception as e:
292
+ # Log failure but continue
293
+ pass
294
+
295
+ # Filter warnings unless in verbose mode
296
+ should_print = True
297
+ if "WARNING:" in line and not global_state.verbose:
298
+ should_print = False
299
+
300
+ # Suppress translation phase output unless in verbose mode
301
+ if phase_name == "translation" and not global_state.verbose:
302
+ should_print = False
303
+
304
+ # finish reading solutions even on broken pipe
305
+ if should_print:
306
+ try:
307
+ # Replace full paths with relative paths in printed output only (unless verbose mode)
308
+ display_line = line
309
+ if not global_state.verbose and ("Created information file" in line or "Created solution file" in line or "Created output" in line):
310
+ # Extract the file path from the line
311
+ # Match patterns like "Created output SAT file" or "Created information file"
312
+ match = re.search(r'(Created (?:\w+ )*(?:file|SAT file) )(.+)$', line)
313
+ if match and self.output_dir:
314
+ prefix = match.group(1)
315
+ full_path = match.group(2).strip()
316
+ rel_path = relativize_path(full_path, self.output_dir)
317
+ display_line = prefix + rel_path + '\n'
318
+
319
+ print(display_line.rstrip(), flush=True)
320
+ except BrokenPipeError:
321
+ pass
322
+
323
+ except (BrokenPipeError, ValueError):
324
+ # silently process remaining lines in the buffer
325
+ pass
326
+
327
+ def read_stderr():
328
+ try:
329
+ while True:
330
+ # Check for interrupt before reading
331
+ if global_state.should_force_termination:
332
+ break
333
+ line = process.stderr.readline()
334
+ if not line: # EOF
335
+ break
336
+ # Always append to capture for log file
337
+ stderr_lines.append(line)
338
+
339
+ # Filter warnings unless in verbose mode
340
+ should_print = True
341
+ if "WARNING:" in line and not global_state.verbose:
342
+ should_print = False
343
+
344
+ # Suppress translation phase output unless in verbose mode
345
+ if phase_name == "translation" and not global_state.verbose:
346
+ should_print = False
347
+
348
+ if should_print:
349
+ print(line.rstrip(), file=sys.stderr, flush=True) # Print to user
350
+ except (BrokenPipeError, ValueError):
351
+ # Handle broken pipe gracefully
352
+ pass
353
+
354
+ # Start reader threads
355
+ stdout_thread = threading.Thread(target=read_stdout)
356
+ stderr_thread = threading.Thread(target=read_stderr)
357
+ stdout_thread.start()
358
+ stderr_thread.start()
359
+
360
+ try:
361
+ # Poll for process completion and check for interrupts
362
+ while process.poll() is None:
363
+ # Check for timeout
364
+ if timeout and (time.time() - start_time) > timeout:
365
+ self.logger.warning(f"Command timed out after {timeout}s")
366
+ timed_out = True
367
+ self._terminate_process_tree(process)
368
+ break
369
+
370
+ # Check for interruption
371
+ if global_state.should_force_termination:
372
+ self.logger.warning("Terminating process due to interrupt")
373
+ interrupted = True
374
+ self._terminate_process_tree(process)
375
+ break
376
+
377
+ # Sleep briefly to avoid busy waiting
378
+ time.sleep(0.1)
379
+
380
+ returncode = process.returncode if process.returncode is not None else -1
381
+
382
+ # Wait for threads to finish reading
383
+ stdout_thread.join(timeout=2.0) # Give threads time to finish
384
+ stderr_thread.join(timeout=2.0)
385
+
386
+ stdout = ''.join(stdout_lines)
387
+ stderr = ''.join(stderr_lines)
388
+
389
+ except subprocess.TimeoutExpired:
390
+ self.logger.warning(f"Command timed out after {timeout}s")
391
+ timed_out = True
392
+ self._terminate_process_tree(process)
393
+ stdout, stderr = process.communicate()
394
+ returncode = -1
395
+ else:
396
+ # Use simple communicate() for commands that don't need streaming
397
+ try:
398
+ stdout, stderr = process.communicate(timeout=timeout)
399
+ returncode = process.returncode
400
+ except subprocess.TimeoutExpired:
401
+ self.logger.warning(f"Command timed out after {timeout}s")
402
+ timed_out = True
403
+ self._terminate_process_tree(process)
404
+ stdout, stderr = process.communicate()
405
+ returncode = -1
406
+
407
+ except (FileNotFoundError, PermissionError, NotADirectoryError) as e:
408
+ # The executable itself could not be started. This is an
409
+ # infrastructure failure (binary missing / not executable), not a
410
+ # per-horizon UNSAT and not a user interrupt, so flag it distinctly
411
+ # (launch_failed) rather than as interrupted: callers abort the scan.
412
+ self.logger.error(f"Could not launch command: {e}")
413
+ return SolverResult(
414
+ command=command,
415
+ returncode=-1,
416
+ stdout="",
417
+ stderr=str(e),
418
+ duration=time.time() - start_time,
419
+ launch_failed=True,
420
+ best_plan_number=0,
421
+ saved_solutions={}
422
+ )
423
+ except Exception as e:
424
+ self.logger.error(f"Error executing command: {e}")
425
+ if process:
426
+ self._terminate_process_tree(process)
427
+ return SolverResult(
428
+ command=command,
429
+ returncode=-1,
430
+ stdout="",
431
+ stderr=str(e),
432
+ duration=time.time() - start_time,
433
+ interrupted=True,
434
+ best_plan_number=0,
435
+ saved_solutions={}
436
+ )
437
+
438
+ # Check if we were interrupted
439
+ if global_state.should_force_termination:
440
+ interrupted = True
441
+ if process and process.poll() is None:
442
+ self._terminate_process_tree(process)
443
+
444
+ duration = time.time() - start_time
445
+
446
+ # Create result
447
+ result = SolverResult(
448
+ command=command,
449
+ returncode=returncode,
450
+ stdout=stdout,
451
+ stderr=stderr,
452
+ duration=duration,
453
+ timed_out=timed_out,
454
+ interrupted=interrupted,
455
+ best_plan_number=best_plan_number if stream_output else 0,
456
+ saved_solutions=saved_solutions if stream_output else {}
457
+ )
458
+
459
+ # Log the result
460
+ self.logger.info(f"Command completed in {duration:.2f}s with return code {returncode}")
461
+
462
+ # `failed` covers three different outcomes and only one of them is a
463
+ # solver failure. A timeout or an interrupt is an expected result the
464
+ # caller decides the meaning of - an anytime solver may have banked a
465
+ # solution worth keeping, and a benchmark's timed-out baseline IS the
466
+ # result - so neither is logged as an error here. Both are already
467
+ # reported at WARNING where they are detected (and a launch failure
468
+ # logs its own error before returning early), so this only records the
469
+ # completed result.
470
+ if result.timed_out or result.interrupted:
471
+ self.logger.info(f"Command did not complete: {result}")
472
+ elif result.failed:
473
+ self.logger.error(f"Command failed: {result}")
474
+
475
+ # Log stderr (always, not just on failure)
476
+ if stderr.rstrip():
477
+ if '\n' in stderr.rstrip():
478
+ self.logger.info("STDERR:\n" + stderr.rstrip())
479
+ else:
480
+ self.logger.info(f"STDERR: {stderr.rstrip()}")
481
+
482
+ # Log stdout
483
+ if stdout.rstrip():
484
+ if '\n' in stdout.rstrip():
485
+ self.logger.info("STDOUT:\n" + stdout.rstrip())
486
+ else:
487
+ self.logger.info(f"STDOUT: {stdout.rstrip()}")
488
+
489
+ # Flush log handlers for immediate output visibility and interrupt robustness
490
+ for handler in self.logger.handlers:
491
+ handler.flush()
492
+
493
+ return result
494
+
495
+ def run_simple(self, *args, **kwargs) -> bool:
496
+ """
497
+ Simplified interface that returns True if command failed.
498
+
499
+ Note: For phase tracking, use run_phase() instead.
500
+ """
501
+ result = self.run(list(args), **kwargs)
502
+ return result.failed
503
+
504
+ def run_phase(self,
505
+ phase_name: str,
506
+ command: List[str],
507
+ expected_outputs: Optional[List[str]] = None,
508
+ horizon_number: Optional[int] = None,
509
+ **kwargs) -> SolverResult:
510
+ """
511
+ Execute a command as a named phase and track results in summary.
512
+
513
+ Args:
514
+ phase_name: Name of the phase ("translation", "solving", "visualisation")
515
+ command: Command and arguments to run
516
+ expected_outputs: List of files expected to be created by this phase
517
+ horizon_number: The horizon number being attempted (for horizon phases)
518
+ **kwargs: Additional arguments passed to SolverRunner.run()
519
+ (timeout, env, stream_output, cwd)
520
+
521
+ Returns:
522
+ SolverResult containing execution details
523
+ """
524
+ if not command:
525
+ raise ValueError("Command cannot be empty")
526
+
527
+ # Create phase result object
528
+ phase = PhaseResult(name=phase_name, command=command.copy())
529
+ self._current_phase = phase
530
+
531
+ self.logger.info(f"Starting phase: {phase_name}")
532
+
533
+ try:
534
+ # Execute the command using existing run method
535
+ result = self.run(command, phase_name=phase_name, **kwargs)
536
+
537
+ # Update phase with results. The outcome fields are recorded
538
+ # alongside `success` so a saved summary can tell a timeout or a
539
+ # missing binary from a solver that ran and failed.
540
+ phase.duration = result.duration
541
+ phase.success = result.success
542
+ phase.returncode = result.returncode
543
+ phase.timed_out = result.timed_out
544
+ phase.launch_failed = result.launch_failed
545
+ phase.error_message = result.stderr if result.failed else ""
546
+
547
+ # Extract warnings from stderr
548
+ if result.stderr:
549
+ for line in result.stderr.split('\n'):
550
+ if 'WARNING:' in line and line.strip():
551
+ phase.warnings.append(line.strip())
552
+
553
+ # Detect solver type and extract statistics
554
+ solver_name = detect_solver_from_command(command)
555
+ phase.solver_stats = SolverStatsExtractor.extract_stats(
556
+ solver_name, result.stdout, result.stderr, result.duration, horizon_number
557
+ )
558
+
559
+ # Add timeout information from the actual result
560
+ if result.timed_out:
561
+ phase.solver_stats['timed_out'] = True
562
+
563
+ # Extract memory usage if available in stats
564
+ if 'peak_memory_kb' in phase.solver_stats:
565
+ phase.peak_memory_kb = phase.solver_stats['peak_memory_kb']
566
+
567
+ # Update solver name in summary if not set
568
+ if not self.summary.solver and solver_name != command[0]:
569
+ self.summary.solver = solver_name
570
+
571
+ # Detect output files that were created and relativize paths
572
+ if expected_outputs:
573
+ phase.output_files = [
574
+ relativize_path(str(f), self.output_dir) for f in expected_outputs
575
+ if Path(f).exists()
576
+ ]
577
+
578
+ # Add phase to summary. add_phase() also updates solution_found, but
579
+ # only for genuine solving phases (it skips translation/modelling/
580
+ # visualisation, whose output can falsely trip solution detection).
581
+ self.summary.add_phase(phase)
582
+
583
+ # Update overall success status (required phases must succeed;
584
+ # best-effort post-processing phases never decide it). finalize()
585
+ # re-derives this authoritatively, honouring final_status.
586
+ self.summary.update_required_success()
587
+
588
+ self.logger.info(f"Phase {phase_name} completed: {result}")
589
+
590
+ except Exception as e:
591
+ # Handle errors gracefully
592
+ phase.success = False
593
+ phase.error_message = str(e)
594
+ phase.duration = 0.0
595
+
596
+ self.summary.add_phase(phase)
597
+
598
+ # Update overall success status (required phases must succeed;
599
+ # best-effort post-processing phases never decide it). finalize()
600
+ # re-derives this authoritatively, honouring final_status.
601
+ self.summary.update_required_success()
602
+
603
+ self.logger.error(f"Phase {phase_name} failed: {e}")
604
+
605
+ # Re-raise the exception
606
+ raise
607
+
608
+ finally:
609
+ self._current_phase = None
610
+
611
+ return result
612
+
613
+ def finalize_summary(self, final_status: str = "done") -> None:
614
+ """
615
+ Finalize and save the run summary.
616
+
617
+ Args:
618
+ final_status: Final status message for the run
619
+ """
620
+ try:
621
+ self.summary.finalize(final_status)
622
+
623
+ if self.summary_file:
624
+ self.summary.save(self.summary_file)
625
+ self.logger.info(f"Summary saved to {self.summary_file}")
626
+
627
+ except Exception as e:
628
+ self.logger.error(f"Failed to save summary: {e}")
629
+
630
+ # Force flush all log handlers to ensure all output is written
631
+ for handler in self.logger.handlers:
632
+ handler.flush()
633
+
634
+ def get_summary(self) -> RunSummary:
635
+ """
636
+ Get the current run summary.
637
+
638
+ Returns:
639
+ Current RunSummary object
640
+ """
641
+ return self.summary
642
+
643
+ def get_phase_result(self, phase_name: str) -> Optional[PhaseResult]:
644
+ """
645
+ Get results for a specific phase.
646
+
647
+ Args:
648
+ phase_name: Name of the phase to retrieve
649
+
650
+ Returns:
651
+ PhaseResult if found, None otherwise
652
+ """
653
+ return self.summary.get_phase(phase_name)
654
+
655
+ def get_total_duration(self) -> float:
656
+ """
657
+ Get total duration of all completed phases.
658
+
659
+ Returns:
660
+ Total duration in seconds
661
+ """
662
+ return self.summary.total_duration
663
+
664
+ def was_successful(self) -> bool:
665
+ """
666
+ Check if all phases completed successfully.
667
+
668
+ Returns:
669
+ True if all phases succeeded, False otherwise
670
+ """
671
+ return self.summary.success
672
+
673
+ def found_solution(self) -> bool:
674
+ """
675
+ Check if a solution was found by any phase.
676
+
677
+ Returns:
678
+ True if solution was found, False otherwise
679
+ """
680
+ return self.summary.solution_found
681
+
682
+ def __repr__(self) -> str:
683
+ """
684
+ String representation of SolverRunner for debugging.
685
+
686
+ Returns:
687
+ String representation containing main aspects of the runner
688
+ """
689
+ parts = [
690
+ f"instance={self.summary.instance!r}",
691
+ f"model={self.summary.model!r}",
692
+ f"solver={self.summary.solver!r}",
693
+ f"timeout={self.timeout}",
694
+ f"log_file={self.log_file}",
695
+ f"summary_file={self.summary_file}",
696
+ f"output_dir={self.output_dir}",
697
+ f"current_phase={self._current_phase.name if self._current_phase else None!r}",
698
+ f"phases={len(self.summary.phases)}"
699
+ ]
700
+ return f"SolverRunner({', '.join(parts)})"
701
+
702
+