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.
- solver_support/__init__.py +132 -0
- solver_support/command_builders.py +334 -0
- solver_support/file_management.py +243 -0
- solver_support/global_state.py +62 -0
- solver_support/horizon_utils.py +350 -0
- solver_support/model_registry.py +792 -0
- solver_support/objective.py +92 -0
- solver_support/run_summary.py +338 -0
- solver_support/solver_base.py +140 -0
- solver_support/solver_enhsp.py +101 -0
- solver_support/solver_fd.py +261 -0
- solver_support/solver_horizon.py +634 -0
- solver_support/solver_runner.py +702 -0
- solver_support/solver_stats.py +724 -0
- solver_support/solver_upfplan.py +168 -0
- solver_support/utilities.py +146 -0
- solver_support-0.1.1.dist-info/METADATA +120 -0
- solver_support-0.1.1.dist-info/RECORD +21 -0
- solver_support-0.1.1.dist-info/WHEEL +5 -0
- solver_support-0.1.1.dist-info/licenses/LICENSE +29 -0
- solver_support-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|