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,634 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Horizon-based solver implementations.
|
|
3
|
+
|
|
4
|
+
This module provides solver classes that use horizon search with optimization support,
|
|
5
|
+
including the base HorizonBasedSolver class and specific implementations for
|
|
6
|
+
Savile Row (Essence Prime) and Conjure (Essence) solvers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, List
|
|
14
|
+
|
|
15
|
+
from .solver_stats import SolverStatsExtractor
|
|
16
|
+
from .solver_runner import SolverExecutableError
|
|
17
|
+
from .global_state import global_state
|
|
18
|
+
from .file_management import SolutionFileNamer, ObjectiveFileManager
|
|
19
|
+
from .solver_base import Solver
|
|
20
|
+
from .horizon_utils import horizon_search, calculate_horizon_timeout
|
|
21
|
+
from .objective import raw_objective_from_solution
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HorizonBasedSolver(Solver):
|
|
25
|
+
"""Base class for solvers that use horizon search with optimization support."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, solver_runner, solver_name: str = 'kissat'):
|
|
28
|
+
"""Initialize with solver runner and optimization tracking."""
|
|
29
|
+
super().__init__(solver_runner)
|
|
30
|
+
self._best_objective = None
|
|
31
|
+
self._has_timeout = False # Track if we're running with a timeout
|
|
32
|
+
self._stop_at_first = False # regime: first stops the scan at the first solving horizon
|
|
33
|
+
self.solver_name = solver_name # the backend config (kissat/minion/...)
|
|
34
|
+
|
|
35
|
+
def _generate_objective_file_path(self, n: int) -> Path:
|
|
36
|
+
"""Generate the objective file path for a given horizon."""
|
|
37
|
+
return ObjectiveFileManager.generate_objective_path(
|
|
38
|
+
self.runner.summary.instance,
|
|
39
|
+
self.runner.summary.solver,
|
|
40
|
+
self.runner.summary.model,
|
|
41
|
+
self.runner.summary.timestamp,
|
|
42
|
+
n,
|
|
43
|
+
tune=self.runner.summary.tune_slot
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _prepare_objective_file(self, obj_file: Path) -> None:
|
|
47
|
+
"""Prepare the objective file by removing any existing file."""
|
|
48
|
+
ObjectiveFileManager.prepare_objective_file(obj_file, self.logger)
|
|
49
|
+
|
|
50
|
+
def _parse_objective_file(self, obj_file: Path) -> Optional[int]:
|
|
51
|
+
"""Parse the objective values file created by Savile Row."""
|
|
52
|
+
return ObjectiveFileManager.parse_objective_file(obj_file)
|
|
53
|
+
|
|
54
|
+
def _extract_score_from_solution(self, solution_file: Path) -> Optional[int]:
|
|
55
|
+
"""Raw objective value from a solution file, via the injected extractor.
|
|
56
|
+
|
|
57
|
+
The raw model-unit value is the correct objective for continue-scan
|
|
58
|
+
comparisons; the reporting multiplier is applied only when a score is
|
|
59
|
+
surfaced to the user, never here.
|
|
60
|
+
"""
|
|
61
|
+
raw_objective = raw_objective_from_solution(str(solution_file))
|
|
62
|
+
if raw_objective is not None:
|
|
63
|
+
self.logger.debug(f"Extracted raw objective value from solution: {raw_objective}")
|
|
64
|
+
return raw_objective
|
|
65
|
+
|
|
66
|
+
def _check_continue_scan(self, obj_file: Optional[Path], n: int, continue_scan: bool, objective_value: Optional[int] = None) -> bool:
|
|
67
|
+
"""Check if horizon scanning should continue based on objective value."""
|
|
68
|
+
if not continue_scan:
|
|
69
|
+
return True # Stop at first solution
|
|
70
|
+
|
|
71
|
+
# regime: first (--tune first) stops as soon as any solution is found,
|
|
72
|
+
# overriding both the continue-scan default and the timeout-driven
|
|
73
|
+
# "keep improving" branch below. objbound is still supplied each horizon
|
|
74
|
+
# (continue_scan stays True), so the model is well-formed; we just stop.
|
|
75
|
+
if self._stop_at_first:
|
|
76
|
+
self.logger.info(f"Solution found at horizon {n}; stopping horizon search (--tune first)")
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
# Use provided objective value, or parse from obj_file if available
|
|
80
|
+
best_value = objective_value
|
|
81
|
+
if best_value is None and obj_file and obj_file.exists():
|
|
82
|
+
best_value = self._parse_objective_file(obj_file)
|
|
83
|
+
|
|
84
|
+
# If we have a timeout, always continue searching for better solutions
|
|
85
|
+
if self._has_timeout:
|
|
86
|
+
if best_value is not None:
|
|
87
|
+
self.logger.info(f"Solution found at horizon {n} with objective value {best_value}, continuing search due to timeout mode")
|
|
88
|
+
return False # Continue to next horizon
|
|
89
|
+
|
|
90
|
+
# Original behavior when no timeout
|
|
91
|
+
if best_value is not None and best_value < 0:
|
|
92
|
+
self.logger.info(f"Solution found at horizon {n} but best objective value is {best_value} (negative), continuing search")
|
|
93
|
+
return False # Continue to next horizon
|
|
94
|
+
else:
|
|
95
|
+
if best_value is not None:
|
|
96
|
+
self.logger.info(f"Solution found at horizon {n} with best objective value {best_value}")
|
|
97
|
+
return True # Stop searching
|
|
98
|
+
|
|
99
|
+
def _set_best_objective(self, value: Optional[int]) -> None:
|
|
100
|
+
"""Set the best objective value found during horizon search."""
|
|
101
|
+
self._best_objective = value
|
|
102
|
+
|
|
103
|
+
def _add_objective_constraint(self, horizon_param_file: Path, n: int) -> None:
|
|
104
|
+
"""Add objective constraint for continue-scan models if best objective exists."""
|
|
105
|
+
if self._best_objective is None:
|
|
106
|
+
# Set appropriate default objbound based on model type
|
|
107
|
+
model_name = self.runner.summary.model.lower()
|
|
108
|
+
if model_name == 'scored3':
|
|
109
|
+
self._best_objective = -100000 # scored3 uses negative penalty-based scoring
|
|
110
|
+
else:
|
|
111
|
+
self._best_objective = 0 # scored and scored2 use positive scores
|
|
112
|
+
|
|
113
|
+
# Add objbound constraint to the parameter file
|
|
114
|
+
with open(horizon_param_file, 'a') as f:
|
|
115
|
+
f.write(f"$ Added by horizon search for continue-scan\n")
|
|
116
|
+
f.write(f"letting objbound be {self._best_objective}\n")
|
|
117
|
+
|
|
118
|
+
self.logger.debug(f"Added objective constraint: obj > {self._best_objective}")
|
|
119
|
+
|
|
120
|
+
def _setup_optimization(self, model_name: str, objbound: Optional[int] = None, timeout: Optional[int] = None):
|
|
121
|
+
"""Setup optimization parameters for the model.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
tuple: (is_optimization_model, continue_scan_enabled)
|
|
125
|
+
"""
|
|
126
|
+
is_opt_model = global_state.model_registry.is_optimization_model(model_name)
|
|
127
|
+
continue_scan = global_state.model_registry.get_continue_scan_enabled(model_name)
|
|
128
|
+
|
|
129
|
+
# Override continue-scan under a timeout for models whose objective is
|
|
130
|
+
# encoded in their solution (the scored* CP models). They already declare
|
|
131
|
+
# continue-scan: true, so this is belt-and-braces - but under a timeout we
|
|
132
|
+
# must keep threading objbound each horizon to improve the salvaged best.
|
|
133
|
+
objective_in_solution = global_state.model_registry.get_objective_in_solution(model_name)
|
|
134
|
+
if objective_in_solution and timeout is not None:
|
|
135
|
+
continue_scan = True
|
|
136
|
+
self.logger.debug(f"Overriding continue-scan to True for objective-in-solution model {model_name} with timeout")
|
|
137
|
+
|
|
138
|
+
# The active tune's regime layers a stop control ON TOP of continue-scan:
|
|
139
|
+
# `--tune first` stops the scan at the first solving horizon instead of
|
|
140
|
+
# scanning on to improve the objective. It is deliberately kept separate
|
|
141
|
+
# from continue_scan, which still governs whether objbound is threaded
|
|
142
|
+
# through each horizon - the scored* models declare `given objbound` and
|
|
143
|
+
# need it supplied even for a single-horizon solve, so flipping
|
|
144
|
+
# continue_scan off here would leave the model ill-formed. A first-regime
|
|
145
|
+
# run stays well-formed and merely stops early (see _check_continue_scan).
|
|
146
|
+
regime = global_state.model_registry.get_tune_regime(self.solver_name, global_state.tune)
|
|
147
|
+
self._stop_at_first = (regime == 'first')
|
|
148
|
+
|
|
149
|
+
if is_opt_model:
|
|
150
|
+
self.logger.debug(f"Model {model_name} is an optimization model")
|
|
151
|
+
if continue_scan:
|
|
152
|
+
self.logger.debug(f"Continue-scan enabled for {model_name}")
|
|
153
|
+
# Set initial objective bound if provided
|
|
154
|
+
if objbound is not None:
|
|
155
|
+
self._set_best_objective(objbound)
|
|
156
|
+
self.logger.debug(f"Set initial objective bound: {objbound}")
|
|
157
|
+
# Track if we have a timeout for enhanced continue-scan behavior
|
|
158
|
+
if timeout is not None:
|
|
159
|
+
self._has_timeout = True
|
|
160
|
+
self.logger.debug(f"Timeout mode enabled for continue-scan")
|
|
161
|
+
|
|
162
|
+
return is_opt_model, continue_scan
|
|
163
|
+
|
|
164
|
+
def _handle_objective_tracking(self, obj_file: Optional[Path], n: int, is_opt_model: bool, continue_scan: bool, solution_file: Optional[Path] = None) -> bool:
|
|
165
|
+
"""Handle objective tracking for optimization models.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
bool: True if horizon search should stop, False to continue
|
|
169
|
+
"""
|
|
170
|
+
current_objective = None
|
|
171
|
+
|
|
172
|
+
# For models whose objective is encoded in the solution, read it from
|
|
173
|
+
# there; other optimisation models report it via the separate .obj file.
|
|
174
|
+
objective_in_solution = global_state.model_registry.get_objective_in_solution(self.runner.summary.model)
|
|
175
|
+
if is_opt_model and objective_in_solution and solution_file and solution_file.exists():
|
|
176
|
+
current_objective = self._extract_score_from_solution(solution_file)
|
|
177
|
+
# Fallback to .obj file if solution parsing fails (e.g., conjure bug with negative scores)
|
|
178
|
+
if current_objective is None and obj_file and obj_file.exists():
|
|
179
|
+
current_objective = self._parse_objective_file(obj_file)
|
|
180
|
+
if current_objective is not None:
|
|
181
|
+
self.logger.debug(f"Used .obj file for objective value: {current_objective}")
|
|
182
|
+
# Fallback to .obj file for other optimization models
|
|
183
|
+
elif is_opt_model and obj_file and obj_file.exists():
|
|
184
|
+
current_objective = self._parse_objective_file(obj_file)
|
|
185
|
+
|
|
186
|
+
if current_objective is not None:
|
|
187
|
+
# Update best objective
|
|
188
|
+
if self._best_objective is None or current_objective > self._best_objective:
|
|
189
|
+
self._set_best_objective(current_objective)
|
|
190
|
+
self.logger.info(f"New best objective value: {current_objective}")
|
|
191
|
+
|
|
192
|
+
# Check if we should continue scanning
|
|
193
|
+
return self._check_continue_scan(obj_file, n, continue_scan, current_objective)
|
|
194
|
+
|
|
195
|
+
def _update_final_stats(self, solver_type: str, result, horizon_n: int) -> None:
|
|
196
|
+
"""Update the final phase with horizon statistics."""
|
|
197
|
+
self.logger.debug(f"Final horizon {horizon_n} - extracting stats")
|
|
198
|
+
final_stats = SolverStatsExtractor.extract_stats(
|
|
199
|
+
solver_type,
|
|
200
|
+
result.stdout if hasattr(result, 'stdout') else "",
|
|
201
|
+
result.stderr if hasattr(result, 'stderr') else "",
|
|
202
|
+
result.duration if hasattr(result, 'duration') else None,
|
|
203
|
+
horizon_number=None
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if self.runner.summary.phases:
|
|
207
|
+
self.runner.summary.phases[-1].solver_stats.update(final_stats)
|
|
208
|
+
|
|
209
|
+
def _add_objbound_metadata(self, is_opt_model: bool, continue_scan: bool) -> None:
|
|
210
|
+
"""Add objbound metadata to the current phase if applicable."""
|
|
211
|
+
if is_opt_model and continue_scan and self._best_objective is not None:
|
|
212
|
+
if self.runner.summary.phases:
|
|
213
|
+
self.runner.summary.phases[-1].solver_stats['objbound'] = self._best_objective
|
|
214
|
+
|
|
215
|
+
def _record_savilerow_times(self, param_file: Path, n: int) -> None:
|
|
216
|
+
"""Attach Savile Row's tailoring/solver time split to horizon n's phase.
|
|
217
|
+
|
|
218
|
+
Reads the ``<param_file>.info`` stats file Savile Row writes for this
|
|
219
|
+
horizon and records ``savilerow_time`` (tailoring) and ``solver_time``
|
|
220
|
+
on the phase, alongside the wall-clock ``duration`` already stored.
|
|
221
|
+
``param_file`` is whatever parameter file Savile Row was run on - the
|
|
222
|
+
Essence Prime ``.param`` for the direct Savile Row path, or the
|
|
223
|
+
Conjure-translated ``.eprime-param`` for the Essence path - since Savile
|
|
224
|
+
Row names the info file after that argument. Called for every horizon
|
|
225
|
+
(not just the solving one), so an intermediate UNSAT horizon's split is
|
|
226
|
+
captured too. Best-effort: a horizon that timed out before Savile Row
|
|
227
|
+
wrote the file, or an old cleaned-up run, records neither. Guarded to
|
|
228
|
+
the matching ``solving-horizon-{n}`` phase so it cannot scribble on an
|
|
229
|
+
unrelated phase.
|
|
230
|
+
"""
|
|
231
|
+
times = SolverStatsExtractor.extract_savilerow_info_times(f"{param_file}.info")
|
|
232
|
+
if not times:
|
|
233
|
+
return
|
|
234
|
+
phases = self.runner.summary.phases
|
|
235
|
+
if phases and phases[-1].name == f"solving-horizon-{n}":
|
|
236
|
+
phases[-1].solver_stats.update(times)
|
|
237
|
+
|
|
238
|
+
def _build_savilerow_command(self, model_path: str, param_path: str, timeout: Optional[int], obj_file: Optional[Path] = None) -> List[str]:
|
|
239
|
+
"""
|
|
240
|
+
Build a Savile Row command with consistent parameters.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
model_path: Path to the model file
|
|
244
|
+
param_path: Path to the parameter file
|
|
245
|
+
timeout: Timeout in seconds
|
|
246
|
+
obj_file: Optional objective file path
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Command list ready for execution
|
|
250
|
+
"""
|
|
251
|
+
# Effective tune: an explicit --tune wins, else the model's pinned
|
|
252
|
+
# default (e.g. scored3 -> safe), else the backend's default_tune
|
|
253
|
+
# (resolved inside build_savilerow_command when this is None).
|
|
254
|
+
registry = global_state.model_registry
|
|
255
|
+
tune = self._effective_tune()
|
|
256
|
+
return registry.build_savilerow_command(
|
|
257
|
+
model_path=model_path,
|
|
258
|
+
param_path=param_path,
|
|
259
|
+
use_srjava=global_state.srjava,
|
|
260
|
+
timeout=timeout,
|
|
261
|
+
objective_file_path=str(obj_file) if obj_file else None,
|
|
262
|
+
extra_options=global_state.options,
|
|
263
|
+
solver_name=self.solver_name,
|
|
264
|
+
tune=tune
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _save_intermediate_solution(self, solution: Path, n: int) -> None:
|
|
268
|
+
"""Copy a horizon-n solution to the standard .best.solution name.
|
|
269
|
+
|
|
270
|
+
Used by the continue-scan timeout regime to bank the best objective
|
|
271
|
+
found so far before extending the horizon. Callers gate this on an
|
|
272
|
+
active timeout run with a known self._best_objective.
|
|
273
|
+
"""
|
|
274
|
+
best_solution_path = Path(SolutionFileNamer.generate_solution_filename(
|
|
275
|
+
self.runner.summary.instance,
|
|
276
|
+
self.runner.summary.solver,
|
|
277
|
+
self.runner.summary.model,
|
|
278
|
+
self.runner.summary.timestamp,
|
|
279
|
+
tune=self.runner.summary.tune_slot,
|
|
280
|
+
horizon=n,
|
|
281
|
+
suffix=".best.solution"
|
|
282
|
+
))
|
|
283
|
+
shutil.copy(solution, best_solution_path)
|
|
284
|
+
self.logger.info(f"Saved intermediate solution from horizon {n} with objective {self._best_objective}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class SavileRowSolver(HorizonBasedSolver):
|
|
288
|
+
"""Solver implementation for Savile Row with horizon search."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, solver_runner, solver_name: str = 'kissat'):
|
|
291
|
+
"""Initialize with solver runner and solver name."""
|
|
292
|
+
super().__init__(solver_runner, solver_name)
|
|
293
|
+
|
|
294
|
+
def solve(self, param_file: Path, **kwargs) -> Optional[Path]:
|
|
295
|
+
"""
|
|
296
|
+
Execute Savile Row solver with horizon search loop.
|
|
297
|
+
Args:
|
|
298
|
+
param_file: Path to parameter file
|
|
299
|
+
**kwargs: Additional arguments including:
|
|
300
|
+
- model_path: Path to Essence Prime model file (required)
|
|
301
|
+
- startat: Starting horizon value (default: 1)
|
|
302
|
+
- stopat: Highest horizon to try (default: 100)
|
|
303
|
+
- objbound: Objective bound constraint for optimization models
|
|
304
|
+
Returns:
|
|
305
|
+
Path to solution file if successful, None otherwise
|
|
306
|
+
"""
|
|
307
|
+
# Extract required kwargs
|
|
308
|
+
model_path = kwargs.get('model_path')
|
|
309
|
+
if not model_path:
|
|
310
|
+
raise ValueError("model_path is required for Savile Row solver")
|
|
311
|
+
|
|
312
|
+
startat = kwargs.get('startat', 1)
|
|
313
|
+
stopat = kwargs.get('stopat', 100)
|
|
314
|
+
objbound = kwargs.get('objbound')
|
|
315
|
+
|
|
316
|
+
# Setup optimization parameters
|
|
317
|
+
model_name = self.runner.summary.model
|
|
318
|
+
is_opt_model, continue_scan = self._setup_optimization(model_name, objbound, self.runner.timeout)
|
|
319
|
+
|
|
320
|
+
# Track total elapsed time for timeout management
|
|
321
|
+
total_start_time = time.time()
|
|
322
|
+
original_timeout = self.runner.timeout
|
|
323
|
+
|
|
324
|
+
def savilerow_horizon_callback(n: int, horizon_param_file: Path) -> Optional[Path]:
|
|
325
|
+
"""Execute Savile Row for a specific horizon."""
|
|
326
|
+
# Calculate remaining timeout for this horizon step
|
|
327
|
+
horizon_timeout = calculate_horizon_timeout(total_start_time, original_timeout, n, self.logger)
|
|
328
|
+
|
|
329
|
+
# Only generate objective file for optimization models
|
|
330
|
+
obj_file = None
|
|
331
|
+
if is_opt_model:
|
|
332
|
+
# Generate objective file name following our naming conventions
|
|
333
|
+
obj_file = self._generate_objective_file_path(n)
|
|
334
|
+
# Remove any existing objective file before this horizon run
|
|
335
|
+
self._prepare_objective_file(obj_file)
|
|
336
|
+
|
|
337
|
+
# Add Savile Row specific constraint
|
|
338
|
+
try:
|
|
339
|
+
with open(horizon_param_file, 'a') as f:
|
|
340
|
+
f.write(f"letting noSteps be {n}\n")
|
|
341
|
+
except Exception as e:
|
|
342
|
+
self.logger.error(f"Failed to add noSteps constraint: {e}")
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
# Build Savile Row command for this horizon using helper method
|
|
346
|
+
savilerow_cmd = self._build_savilerow_command(
|
|
347
|
+
str(model_path), str(horizon_param_file), horizon_timeout, obj_file
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Expected solution file
|
|
351
|
+
expected_solution = horizon_param_file.with_suffix('.param.solution')
|
|
352
|
+
|
|
353
|
+
# Run Savile Row for this horizon
|
|
354
|
+
result = self.runner.run_phase(
|
|
355
|
+
f"solving-horizon-{n}",
|
|
356
|
+
savilerow_cmd,
|
|
357
|
+
expected_outputs=[str(expected_solution)],
|
|
358
|
+
horizon_number=n
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Record Savile Row's tailoring vs solver time split from the .info
|
|
362
|
+
# file for this horizon (best-effort; absent on a hard timeout).
|
|
363
|
+
self._record_savilerow_times(horizon_param_file, n)
|
|
364
|
+
|
|
365
|
+
# If the solver executable could not be launched, abort the whole
|
|
366
|
+
# scan: a missing/non-executable binary will not appear on a later
|
|
367
|
+
# horizon, so marching on to the horizon cap only wastes time and
|
|
368
|
+
# buries the real cause. Surfaces as final_status=launch-failed.
|
|
369
|
+
if getattr(result, 'launch_failed', False):
|
|
370
|
+
raise SolverExecutableError(
|
|
371
|
+
f"Savile Row could not be launched at horizon {n}: {result.stderr.strip()}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Add objbound to phase metadata if it's being used
|
|
375
|
+
self._add_objbound_metadata(is_opt_model, continue_scan)
|
|
376
|
+
|
|
377
|
+
# Check for timeout - if so, stop horizon search immediately
|
|
378
|
+
if result.timed_out:
|
|
379
|
+
self.logger.warning(f"Horizon {n} timed out - stopping horizon search")
|
|
380
|
+
# Use a special return value to signal timeout to horizon_search
|
|
381
|
+
raise TimeoutError(f"Solver timed out at horizon {n}")
|
|
382
|
+
|
|
383
|
+
if result.success and expected_solution.exists():
|
|
384
|
+
# Handle objective tracking for optimization models
|
|
385
|
+
should_stop = self._handle_objective_tracking(obj_file, n, is_opt_model, continue_scan, expected_solution)
|
|
386
|
+
|
|
387
|
+
if should_stop:
|
|
388
|
+
# This is the final horizon - extract final stats
|
|
389
|
+
self._update_final_stats('savilerow', result, n)
|
|
390
|
+
return expected_solution
|
|
391
|
+
elif is_opt_model and continue_scan:
|
|
392
|
+
# For timeout mode with continue-scan, save the solution but continue searching
|
|
393
|
+
if self._has_timeout and self._best_objective is not None:
|
|
394
|
+
# Save this solution as the best so far using standard naming
|
|
395
|
+
self._save_intermediate_solution(expected_solution, n)
|
|
396
|
+
return None # Continue to next horizon
|
|
397
|
+
else:
|
|
398
|
+
return expected_solution # Fallback
|
|
399
|
+
else:
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
return horizon_search(
|
|
403
|
+
self.runner, param_file, startat, savilerow_horizon_callback, self.logger,
|
|
404
|
+
stopat=stopat,
|
|
405
|
+
extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class ConjureSolver(HorizonBasedSolver):
|
|
410
|
+
"""Solver implementation for Conjure (Essence to EPrime to solution)."""
|
|
411
|
+
|
|
412
|
+
def solve(self, param_file: Path, **kwargs) -> Optional[Path]:
|
|
413
|
+
"""
|
|
414
|
+
Execute Conjure solver with horizon search loop for Essence models.
|
|
415
|
+
Args:
|
|
416
|
+
param_file: Path to Essence parameter file
|
|
417
|
+
**kwargs: Additional arguments including:
|
|
418
|
+
- model_path: Path to Essence model file (required)
|
|
419
|
+
- startat: Starting horizon value (default: 1)
|
|
420
|
+
- stopat: Highest horizon to try (default: 100)
|
|
421
|
+
Returns:
|
|
422
|
+
Path to solution file if successful, None otherwise
|
|
423
|
+
"""
|
|
424
|
+
# Extract required kwargs
|
|
425
|
+
model_path = kwargs.get('model_path')
|
|
426
|
+
if not model_path:
|
|
427
|
+
raise ValueError("model_path is required for Conjure solver")
|
|
428
|
+
|
|
429
|
+
startat = kwargs.get('startat', 1)
|
|
430
|
+
stopat = kwargs.get('stopat', 100)
|
|
431
|
+
|
|
432
|
+
# Step 1: Modelling - create EPrime from Essence
|
|
433
|
+
epm_path = self._do_conjure_modelling(str(model_path))
|
|
434
|
+
if not epm_path:
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
# Setup optimization parameters
|
|
438
|
+
model_name = self.runner.summary.model
|
|
439
|
+
is_opt_model, continue_scan = self._setup_optimization(model_name, timeout=self.runner.timeout)
|
|
440
|
+
|
|
441
|
+
# Track total elapsed time for timeout management
|
|
442
|
+
total_start_time = time.time()
|
|
443
|
+
original_timeout = self.runner.timeout
|
|
444
|
+
|
|
445
|
+
def conjure_horizon_callback(n: int, horizon_param_file: Path) -> Optional[Path]:
|
|
446
|
+
"""Execute Conjure solve and translate for a specific horizon."""
|
|
447
|
+
# Calculate remaining timeout for this horizon step
|
|
448
|
+
horizon_timeout = calculate_horizon_timeout(total_start_time, original_timeout, n, self.logger)
|
|
449
|
+
|
|
450
|
+
# Add Conjure specific constraints
|
|
451
|
+
try:
|
|
452
|
+
with open(horizon_param_file, 'a') as f:
|
|
453
|
+
f.write(f"letting maxMoves be {n}\n")
|
|
454
|
+
f.write(f"letting horizon be {n}\n")
|
|
455
|
+
except Exception as e:
|
|
456
|
+
self.logger.error(f"Failed to add horizon constraints: {e}")
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
# Execute the solve and translate pipeline
|
|
460
|
+
solution_file = self._do_conjure_solve_translate(horizon_param_file, epm_path, n, is_opt_model, continue_scan, horizon_timeout)
|
|
461
|
+
|
|
462
|
+
if solution_file:
|
|
463
|
+
# Handle objective tracking for optimization models
|
|
464
|
+
if is_opt_model:
|
|
465
|
+
obj_file = self._generate_objective_file_path(n)
|
|
466
|
+
else:
|
|
467
|
+
obj_file = None
|
|
468
|
+
|
|
469
|
+
should_stop = self._handle_objective_tracking(obj_file, n, is_opt_model, continue_scan, solution_file)
|
|
470
|
+
|
|
471
|
+
if should_stop:
|
|
472
|
+
# This is the final horizon - extract final stats
|
|
473
|
+
if is_opt_model and continue_scan:
|
|
474
|
+
self._update_final_stats('savilerow', type('obj', (), {'stdout': '', 'stderr': '', 'duration': None}), n)
|
|
475
|
+
return solution_file
|
|
476
|
+
elif is_opt_model and continue_scan:
|
|
477
|
+
# For timeout mode with continue-scan, save the solution but continue searching
|
|
478
|
+
if self._has_timeout and self._best_objective is not None:
|
|
479
|
+
# Save this solution as the best so far using standard naming
|
|
480
|
+
self._save_intermediate_solution(solution_file, n)
|
|
481
|
+
return None # Continue to next horizon
|
|
482
|
+
else:
|
|
483
|
+
return solution_file
|
|
484
|
+
else:
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
# Step 2: Horizon-extending loop (minimum horizon 2 for Essence models)
|
|
488
|
+
return horizon_search(
|
|
489
|
+
self.runner, param_file, startat, conjure_horizon_callback, self.logger,
|
|
490
|
+
min_horizon=2,
|
|
491
|
+
stopat=stopat,
|
|
492
|
+
extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def _do_conjure_modelling(self, model_path: str) -> Optional[Path]:
|
|
496
|
+
"""Execute Conjure modelling phase to create EPrime from Essence."""
|
|
497
|
+
# Check if EPrime file already exists and is newer than the Essence model
|
|
498
|
+
epm_file = Path(f"{Path(model_path).stem}.eprime")
|
|
499
|
+
|
|
500
|
+
if epm_file.exists():
|
|
501
|
+
essence_mtime = os.path.getmtime(model_path)
|
|
502
|
+
eprime_mtime = os.path.getmtime(epm_file)
|
|
503
|
+
|
|
504
|
+
if eprime_mtime > essence_mtime:
|
|
505
|
+
self.logger.info(f"Reusing existing EPrime model: {epm_file} (newer than {Path(model_path).name})")
|
|
506
|
+
return epm_file
|
|
507
|
+
else:
|
|
508
|
+
self.logger.info(f"EPrime model exists but is older than Essence model, regenerating")
|
|
509
|
+
|
|
510
|
+
# Build Conjure modelling command
|
|
511
|
+
modelling_cmd = global_state.model_registry.build_conjure_command(
|
|
512
|
+
action="modelling",
|
|
513
|
+
model_path=model_path
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
self.logger.warning("Generating EPrime model from Essence using Conjure")
|
|
517
|
+
|
|
518
|
+
# Conjure outputs to conjure-output/model000001.eprime by default (relative to cwd)
|
|
519
|
+
expected_conjure_file = Path("conjure-output") / "model000001.eprime"
|
|
520
|
+
|
|
521
|
+
result = self.runner.run_phase("modelling", modelling_cmd, expected_outputs=[str(expected_conjure_file)])
|
|
522
|
+
|
|
523
|
+
self.logger.debug(f"Modelling result: success={result.success}, file exists={expected_conjure_file.exists()}, path={expected_conjure_file}")
|
|
524
|
+
|
|
525
|
+
if result.success and expected_conjure_file.exists():
|
|
526
|
+
# Move the file to the expected location for consistency
|
|
527
|
+
self.logger.debug(f"Moving {expected_conjure_file} to {epm_file}")
|
|
528
|
+
expected_conjure_file.rename(epm_file)
|
|
529
|
+
self.logger.info(f"EPrime model generated: {epm_file}")
|
|
530
|
+
return epm_file
|
|
531
|
+
else:
|
|
532
|
+
self.logger.error("EPrime model generation failed")
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
def _do_conjure_solve_translate(self, horizon_param_file: Path, epm_path: Path, n: int, is_opt_model: bool = False, continue_scan: bool = False, horizon_timeout: Optional[int] = None) -> Optional[Path]:
|
|
536
|
+
"""Execute Conjure solve and translate phase for a specific horizon."""
|
|
537
|
+
param_stem = horizon_param_file.stem.rsplit('-', 1)[0] # Remove horizon suffix
|
|
538
|
+
n2 = f"{n:02d}"
|
|
539
|
+
|
|
540
|
+
# Step 1: Translate Essence params to EPrime format
|
|
541
|
+
epp_file = Path(f"{param_stem}-{n2}.eprime-param")
|
|
542
|
+
|
|
543
|
+
translate_cmd = global_state.model_registry.build_conjure_command(
|
|
544
|
+
action="translate-parameter",
|
|
545
|
+
essence_param=horizon_param_file,
|
|
546
|
+
eprime=epm_path
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
result = self.runner.run_phase(
|
|
550
|
+
f"translate-param-{n}",
|
|
551
|
+
translate_cmd,
|
|
552
|
+
expected_outputs=[str(epp_file)]
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
if not result.success:
|
|
556
|
+
self.logger.error(f"Could not translate Essence param to EPrime for horizon {n}")
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
# Step 2: Run Savile Row on EPrime model
|
|
560
|
+
eps_file = epp_file.with_suffix('.eprime-param.solution')
|
|
561
|
+
|
|
562
|
+
# Only generate objective file for optimization models
|
|
563
|
+
obj_file = None
|
|
564
|
+
if is_opt_model:
|
|
565
|
+
# Generate objective file name for this horizon
|
|
566
|
+
obj_file = self._generate_objective_file_path(n)
|
|
567
|
+
|
|
568
|
+
# Remove any existing objective file before this horizon run
|
|
569
|
+
self._prepare_objective_file(obj_file)
|
|
570
|
+
|
|
571
|
+
# Build Savile Row command for this horizon using helper method
|
|
572
|
+
savilerow_cmd = self._build_savilerow_command(
|
|
573
|
+
str(epm_path), str(epp_file), horizon_timeout, obj_file
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
result = self.runner.run_phase(
|
|
577
|
+
f"solving-horizon-{n}",
|
|
578
|
+
savilerow_cmd,
|
|
579
|
+
expected_outputs=[str(eps_file)],
|
|
580
|
+
horizon_number=n
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# Record Savile Row's tailoring vs solver time split. Here Savile Row is
|
|
584
|
+
# run directly on the translated eprime-param (not via conjure), so its
|
|
585
|
+
# .info sits next to epp_file, not the essence param file.
|
|
586
|
+
self._record_savilerow_times(epp_file, n)
|
|
587
|
+
|
|
588
|
+
# Add objbound to phase metadata if it's being used. continue_scan is the
|
|
589
|
+
# value resolved once in _setup_optimization (registry plus any overrides)
|
|
590
|
+
# and threaded in alongside is_opt_model - not re-read from the registry
|
|
591
|
+
# here, so the metadata can never disagree with the scan behaviour it
|
|
592
|
+
# describes.
|
|
593
|
+
self._add_objbound_metadata(is_opt_model, continue_scan)
|
|
594
|
+
|
|
595
|
+
# Check for timeout - if so, stop horizon search immediately
|
|
596
|
+
if result.timed_out:
|
|
597
|
+
self.logger.warning(f"Horizon {n} timed out - stopping horizon search")
|
|
598
|
+
raise TimeoutError(f"Solver timed out at horizon {n}")
|
|
599
|
+
|
|
600
|
+
if not (result.success and eps_file.exists()):
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
self.logger.info(f"EPrime solution found at horizon {n}")
|
|
604
|
+
|
|
605
|
+
# Step 3: Translate EPrime solution back to Essence format
|
|
606
|
+
essence_solution = Path(f"{param_stem}-{n2}.solution")
|
|
607
|
+
|
|
608
|
+
translate_solution_cmd = global_state.model_registry.build_conjure_command(
|
|
609
|
+
action="translate-solution",
|
|
610
|
+
essence_param=horizon_param_file,
|
|
611
|
+
eprime=epm_path,
|
|
612
|
+
eprime_solution=eps_file,
|
|
613
|
+
essence_solution=essence_solution
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
result = self.runner.run_phase(
|
|
617
|
+
f"translate-solution-{n}",
|
|
618
|
+
translate_solution_cmd,
|
|
619
|
+
expected_outputs=[str(essence_solution)]
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
if not (result.success and essence_solution.exists()):
|
|
623
|
+
self.logger.error(f"Could not translate EPrime solution back to Essence for horizon {n}")
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
self.logger.info(f"Essence solution found at horizon {n}")
|
|
627
|
+
|
|
628
|
+
# Create final solution file with proper naming using helper methods
|
|
629
|
+
final_solution = self._generate_solution_file_path(suffix=f".{n}.solution")
|
|
630
|
+
|
|
631
|
+
if self._copy_solution_file(essence_solution, final_solution):
|
|
632
|
+
return final_solution
|
|
633
|
+
else:
|
|
634
|
+
return essence_solution
|