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,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File management utilities for puzzle solvers.
|
|
3
|
+
|
|
4
|
+
This module provides centralized file naming conventions, path generation,
|
|
5
|
+
and file operations used by various solver implementations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalise_tune(tune) -> List[str]:
|
|
15
|
+
"""Normalise a ``--tune`` value to a list of names.
|
|
16
|
+
|
|
17
|
+
Accepts None, a single name, a comma-separated ``a,b`` string, or a list.
|
|
18
|
+
"""
|
|
19
|
+
if not tune:
|
|
20
|
+
return []
|
|
21
|
+
if isinstance(tune, str):
|
|
22
|
+
return [t for t in tune.split(',') if t]
|
|
23
|
+
return [t for t in tune if t]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def compute_tune_slot(tune=None, options=None) -> str:
|
|
27
|
+
"""Build the ``‹tune›`` filename slot that sits between solver and model.
|
|
28
|
+
|
|
29
|
+
The slot lets runs that differ only in their configuration produce distinct,
|
|
30
|
+
directly comparable filenames (the user benchmarks e.g. default / -O1 / -O2
|
|
31
|
+
against each other, so those must not collide). It is:
|
|
32
|
+
|
|
33
|
+
- ``defopt`` when neither ``--tune`` nor ``--options`` was given (the
|
|
34
|
+
default configuration);
|
|
35
|
+
- the requested tune name when ``--tune`` was used (a comma-separated
|
|
36
|
+
``--tune a,b`` becomes ``a+b``, since commas read awkwardly in filenames);
|
|
37
|
+
- suffixed ``+‹shorthash›`` - the first 8 hex of a sha1 of the joined raw
|
|
38
|
+
options string, git-style and order-sensitive - whenever raw ``--options``
|
|
39
|
+
are present, so two different option sets never share a filename.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
tune: The requested ``--tune`` value (None, a name, a comma-separated
|
|
43
|
+
string, or a list of names). The *requested* value, not the
|
|
44
|
+
model/backend default, so an unadorned run always reads ``defopt``.
|
|
45
|
+
options: The raw ``--options`` pass-through tokens (list; may be empty).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The slot string, e.g. ``defopt``, ``hmax``, ``defopt+a1b2c3d4``.
|
|
49
|
+
"""
|
|
50
|
+
names = _normalise_tune(tune)
|
|
51
|
+
base = '+'.join(names) if names else 'defopt'
|
|
52
|
+
|
|
53
|
+
options = options or []
|
|
54
|
+
if options:
|
|
55
|
+
joined = ' '.join(options)
|
|
56
|
+
shorthash = hashlib.sha1(joined.encode('utf-8')).hexdigest()[:8]
|
|
57
|
+
return f"{base}+{shorthash}"
|
|
58
|
+
return base
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compute_options_label(tune=None, options=None) -> str:
|
|
62
|
+
"""Build the human-readable configuration label recorded in the summary.
|
|
63
|
+
|
|
64
|
+
The readable sibling of :func:`compute_tune_slot`: it keeps the raw option
|
|
65
|
+
text (``O2``) instead of hashing it, so downstream plotting and summary tools
|
|
66
|
+
can fold runs into legible per-configuration series. The
|
|
67
|
+
label is the ``+``-join of the tune names and the dash-stripped options, or
|
|
68
|
+
``defopt`` when there is neither:
|
|
69
|
+
|
|
70
|
+
- ``()`` -> ``defopt`` (the default configuration)
|
|
71
|
+
- ``-O2`` -> ``O2`` (options only; unchanged from the old label)
|
|
72
|
+
- ``--tune hmax`` -> ``hmax`` (tune only)
|
|
73
|
+
- ``--tune hmax -O2`` -> ``hmax+O2``
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
tune: The requested ``--tune`` value (see :func:`compute_tune_slot`).
|
|
77
|
+
options: The raw ``--options`` pass-through tokens (list; may be empty).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The readable label, e.g. ``defopt``, ``O2``, ``hmax+O2``.
|
|
81
|
+
"""
|
|
82
|
+
names = _normalise_tune(tune)
|
|
83
|
+
readable_options = [o.lstrip('-') for o in (options or [])]
|
|
84
|
+
parts = names + readable_options
|
|
85
|
+
return '+'.join(parts) if parts else 'defopt'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SolutionFileNamer:
|
|
89
|
+
"""Manages consistent naming conventions for solution files."""
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def generate_solution_filename(instance: str, solver: str, model: str,
|
|
93
|
+
timestamp: str, tune: str = "",
|
|
94
|
+
horizon: Optional[int] = None,
|
|
95
|
+
suffix: str = ".solution") -> str:
|
|
96
|
+
"""
|
|
97
|
+
Generate a standardized solution filename.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
instance: Instance/problem name
|
|
101
|
+
solver: Solver name
|
|
102
|
+
tune: Optional tune slot (see compute_tune_slot); inserted between
|
|
103
|
+
solver and model when non-empty, omitted for backward compat
|
|
104
|
+
when empty
|
|
105
|
+
model: Model name
|
|
106
|
+
timestamp: Timestamp string
|
|
107
|
+
horizon: Optional horizon value
|
|
108
|
+
suffix: File suffix (default: ".solution")
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Formatted filename string
|
|
112
|
+
"""
|
|
113
|
+
if horizon is not None:
|
|
114
|
+
horizon_str = f"-{horizon:02d}"
|
|
115
|
+
else:
|
|
116
|
+
horizon_str = ""
|
|
117
|
+
|
|
118
|
+
tune_str = f"-{tune}" if tune else ""
|
|
119
|
+
|
|
120
|
+
return f"{instance}-{solver}{tune_str}-{model}-{timestamp}{horizon_str}{suffix}"
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def generate_solution_path(instance: str, solver: str,
|
|
124
|
+
model: str, timestamp: str, tune: str = "",
|
|
125
|
+
horizon: Optional[int] = None,
|
|
126
|
+
suffix: str = ".solution") -> Path:
|
|
127
|
+
"""
|
|
128
|
+
Generate a solution file path (relative to current directory).
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
instance: Instance/problem name
|
|
132
|
+
solver: Solver name
|
|
133
|
+
model: Model name
|
|
134
|
+
timestamp: Timestamp string
|
|
135
|
+
tune: Optional tune slot (see compute_tune_slot)
|
|
136
|
+
horizon: Optional horizon value
|
|
137
|
+
suffix: File suffix
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Path to solution file (relative to current directory)
|
|
141
|
+
"""
|
|
142
|
+
filename = SolutionFileNamer.generate_solution_filename(
|
|
143
|
+
instance, solver, model, timestamp, tune=tune, horizon=horizon,
|
|
144
|
+
suffix=suffix
|
|
145
|
+
)
|
|
146
|
+
return Path(filename)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ObjectiveFileManager:
|
|
150
|
+
"""Manages objective value files for optimization solvers."""
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def generate_objective_path(instance: str, solver: str,
|
|
154
|
+
model: str, timestamp: str, horizon: int,
|
|
155
|
+
tune: str = "") -> Path:
|
|
156
|
+
"""
|
|
157
|
+
Generate objective file path (relative to current directory).
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
instance: Instance/problem name
|
|
161
|
+
solver: Solver name
|
|
162
|
+
model: Model name
|
|
163
|
+
timestamp: Timestamp string
|
|
164
|
+
horizon: Horizon value
|
|
165
|
+
tune: Optional tune slot (see compute_tune_slot)
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Path to objective file (relative to current directory)
|
|
169
|
+
"""
|
|
170
|
+
horizon_str = f"{horizon:02d}"
|
|
171
|
+
tune_str = f"-{tune}" if tune else ""
|
|
172
|
+
filename = f"{instance}-{solver}{tune_str}-{model}-{timestamp}-{horizon_str}.obj"
|
|
173
|
+
return Path(filename)
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def prepare_objective_file(obj_file: Path, logger: Optional[logging.Logger] = None) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Prepare objective file by removing any existing file.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
obj_file: Path to objective file
|
|
182
|
+
logger: Optional logger for debug messages
|
|
183
|
+
"""
|
|
184
|
+
if obj_file.exists():
|
|
185
|
+
try:
|
|
186
|
+
obj_file.unlink()
|
|
187
|
+
if logger:
|
|
188
|
+
logger.debug(f"Removed existing objective file: {obj_file}")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
if logger:
|
|
191
|
+
logger.warning(f"Failed to remove existing objective file: {e}")
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def parse_objective_file(obj_file: Path) -> Optional[int]:
|
|
195
|
+
"""
|
|
196
|
+
Parse objective values file created by Savile Row or similar solvers.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
obj_file: Path to objective file
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Best objective value found, or None if parsing fails
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
if not obj_file.exists():
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
with open(obj_file, 'r') as f:
|
|
209
|
+
lines = f.readlines()
|
|
210
|
+
|
|
211
|
+
if not lines:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
# Parse the last line which contains the best objective value found
|
|
215
|
+
# Format: "objective_value time_in_seconds"
|
|
216
|
+
last_line = lines[-1].strip()
|
|
217
|
+
if last_line:
|
|
218
|
+
parts = last_line.split()
|
|
219
|
+
if parts and parts[0].lstrip('-').isdigit():
|
|
220
|
+
return int(parts[0])
|
|
221
|
+
|
|
222
|
+
return None
|
|
223
|
+
except Exception:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class VisualizationFileManager:
|
|
228
|
+
"""Manages visualization output files."""
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def open_templated_file(n: int, prefix: str = "vis", encoding: str = 'utf-8'):
|
|
232
|
+
"""
|
|
233
|
+
Open file number n out of a sequence of output files.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
n: Sequence number
|
|
237
|
+
prefix: File prefix (default: "vis")
|
|
238
|
+
encoding: File encoding (default: utf-8)
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Opened file handle
|
|
242
|
+
"""
|
|
243
|
+
return open(f"{prefix}-{n:03d}.txt", 'w', encoding=encoding)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Global state for interrupt handling and runtime configuration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import signal
|
|
7
|
+
|
|
8
|
+
class GlobalState:
|
|
9
|
+
"""Global state for tracking interrupts and runtime configuration."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.interrupt_count = 0
|
|
13
|
+
self.debug = False
|
|
14
|
+
self.verbose = False
|
|
15
|
+
self.srjava = False
|
|
16
|
+
# Free-form options passed through with --options to the active solver
|
|
17
|
+
# toolchain (kissat/minion Savile Row, fd/symk, enhsp). The runner does
|
|
18
|
+
# NOT interpret these: interpreted presets belong on --tune instead. A
|
|
19
|
+
# list, since --options is repeatable.
|
|
20
|
+
self.options = []
|
|
21
|
+
# Tune preset for the active backend, selected with --tune and resolved
|
|
22
|
+
# against the backend's presets in the registry (fd: blind/hmax/ff/lmcut;
|
|
23
|
+
# symk: fw/bw/bd). None means the backend's default.
|
|
24
|
+
self.tune = None
|
|
25
|
+
# Model registry (lazy loaded)
|
|
26
|
+
self._model_registry = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def interrupted(self) -> bool:
|
|
30
|
+
return self.interrupt_count > 0
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def should_force_termination(self) -> bool:
|
|
34
|
+
return self.interrupt_count >= 2
|
|
35
|
+
|
|
36
|
+
def on_interrupt(self, signum: int = None) -> None:
|
|
37
|
+
"""Handle interrupt signals. SIGTERM forces immediate termination."""
|
|
38
|
+
if signum == signal.SIGTERM:
|
|
39
|
+
self.interrupt_count = 2 # Force immediate termination
|
|
40
|
+
else:
|
|
41
|
+
self.interrupt_count += 1
|
|
42
|
+
|
|
43
|
+
def log_level(self) -> int:
|
|
44
|
+
"""Return appropriate logging level based on debug/verbose flags."""
|
|
45
|
+
if self.debug:
|
|
46
|
+
return logging.DEBUG
|
|
47
|
+
elif self.verbose:
|
|
48
|
+
return logging.INFO
|
|
49
|
+
else:
|
|
50
|
+
return logging.WARNING
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def model_registry(self):
|
|
54
|
+
"""Get the model registry, creating it if needed (lazy loading to avoid circular imports)."""
|
|
55
|
+
if self._model_registry is None:
|
|
56
|
+
from .model_registry import ModelRegistry
|
|
57
|
+
self._model_registry = ModelRegistry()
|
|
58
|
+
return self._model_registry
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Global instance
|
|
62
|
+
global_state = GlobalState()
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Horizon search utilities.
|
|
3
|
+
|
|
4
|
+
This module provides generic horizon-extending search functionality
|
|
5
|
+
that can be used by different solver types (Savile Row, Conjure, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Callable, Tuple
|
|
13
|
+
|
|
14
|
+
from .file_management import SolutionFileNamer
|
|
15
|
+
from .global_state import global_state
|
|
16
|
+
from .objective import reported_score_from_timeout_solution
|
|
17
|
+
from .solver_runner import SolverRunner
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reconcile_horizon_bounds(startat: int, stopat: int,
|
|
21
|
+
min_horizon: int = 1) -> Tuple[int, int]:
|
|
22
|
+
"""Reconcile the horizon-scan bounds into an effective (start, stop) pair.
|
|
23
|
+
|
|
24
|
+
This is the single source of truth for how the requested bounds are
|
|
25
|
+
resolved, shared by the CLI (``parse_args`` in ``scripts/run.py``) and the
|
|
26
|
+
core loop (``horizon_search``) so the two cannot drift. The rule:
|
|
27
|
+
|
|
28
|
+
- the start is floored to ``min_horizon`` - a solver-specific minimum
|
|
29
|
+
(e.g. Conjure/Essence models need at least 2), 1 otherwise; then
|
|
30
|
+
- the stop is raised to at least the (floored) start, so the scan always
|
|
31
|
+
tries at least the starting horizon rather than running an empty loop.
|
|
32
|
+
|
|
33
|
+
A contradictory ``startat=5, stopat=3`` therefore yields ``(5, 5)`` - the
|
|
34
|
+
start wins and the cap is raised to meet it - not an empty range. The CLI
|
|
35
|
+
omits ``min_horizon`` (the per-solver floor is applied later, in the core);
|
|
36
|
+
it relies only on the start-vs-stop half of the rule.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
startat: Requested starting horizon
|
|
40
|
+
stopat: Requested highest horizon to try
|
|
41
|
+
min_horizon: Solver-specific minimum horizon (default 1)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
``(effective_start, effective_stop)`` with ``min_horizon <= start <= stop``.
|
|
45
|
+
"""
|
|
46
|
+
effective_start = max(startat, min_horizon)
|
|
47
|
+
effective_stop = max(stopat, effective_start)
|
|
48
|
+
return effective_start, effective_stop
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _create_main_solution_from_best(runner: SolverRunner, best_solution_found: Path,
|
|
52
|
+
logger: logging.Logger, context: str) -> Path:
|
|
53
|
+
"""
|
|
54
|
+
Helper function to create the main solution file from the best solution found.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
runner: SolverRunner instance
|
|
58
|
+
best_solution_found: Path to the best solution file
|
|
59
|
+
logger: Logger instance
|
|
60
|
+
context: Context string for logging (e.g., "before timeout", "within horizon limit")
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Path to the created main solution file
|
|
64
|
+
"""
|
|
65
|
+
logger.info(f"Found best solution {context}: {best_solution_found}")
|
|
66
|
+
# Create the main solution file by copying the best one
|
|
67
|
+
main_solution = Path(SolutionFileNamer.generate_solution_filename(
|
|
68
|
+
runner.summary.instance, runner.summary.solver,
|
|
69
|
+
runner.summary.model, runner.summary.timestamp,
|
|
70
|
+
tune=runner.summary.tune_slot
|
|
71
|
+
))
|
|
72
|
+
shutil.copy(best_solution_found, main_solution)
|
|
73
|
+
logger.info(f"Created main solution file: {main_solution}")
|
|
74
|
+
# Extract and set final score for timeout-based solutions
|
|
75
|
+
_extract_final_score_for_timeout_solution(runner, main_solution, logger)
|
|
76
|
+
return main_solution
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def horizon_search(runner: SolverRunner,
|
|
80
|
+
param_file: Path,
|
|
81
|
+
startat: int,
|
|
82
|
+
horizon_callback: Callable[[int, Path], Optional[Path]],
|
|
83
|
+
logger: logging.Logger,
|
|
84
|
+
min_horizon: int = 1,
|
|
85
|
+
stopat: int = 100,
|
|
86
|
+
extra_constraints_callback: Optional[Callable[[Path, int], None]] = None) -> Optional[Path]:
|
|
87
|
+
"""
|
|
88
|
+
Generic horizon-extending search loop.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
runner: SolverRunner instance for execution (contains output directory)
|
|
92
|
+
param_file: Original parameter file
|
|
93
|
+
startat: Starting horizon value
|
|
94
|
+
horizon_callback: Function(n, horizon_param_file) -> Optional[Path]
|
|
95
|
+
Returns solution file if found, None to continue
|
|
96
|
+
logger: Logger instance
|
|
97
|
+
min_horizon: Minimum allowed horizon value
|
|
98
|
+
stopat: Highest horizon to try (inclusive upper limit of the scan)
|
|
99
|
+
extra_constraints_callback: Optional function(horizon_param_file, n) to add extra constraints
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Path to solution file if found, None otherwise
|
|
103
|
+
"""
|
|
104
|
+
# Get output directory from runner
|
|
105
|
+
output_dir = Path(runner.summary.output_dir)
|
|
106
|
+
|
|
107
|
+
# Single source of truth for the start/stop reconciliation, shared with the
|
|
108
|
+
# CLI so the two cannot drift (see reconcile_horizon_bounds). Guarantees a
|
|
109
|
+
# non-empty scan of at least the starting horizon.
|
|
110
|
+
effective_start, effective_stop = reconcile_horizon_bounds(
|
|
111
|
+
startat, stopat, min_horizon)
|
|
112
|
+
logger.info(f"Starting horizon search from {effective_start} to {effective_stop}")
|
|
113
|
+
|
|
114
|
+
# Track the best solution found for timeout-based continue-scan
|
|
115
|
+
best_solution_found = None
|
|
116
|
+
|
|
117
|
+
for n in range(effective_start, effective_stop + 1): # inclusive of effective_stop
|
|
118
|
+
# Check for interruption before starting each horizon
|
|
119
|
+
# First interrupt: stop at natural phase boundary (between horizons)
|
|
120
|
+
# Second interrupt: force immediate termination
|
|
121
|
+
if global_state.interrupted:
|
|
122
|
+
if global_state.should_force_termination:
|
|
123
|
+
logger.warning("Forcing immediate termination of horizon search")
|
|
124
|
+
else:
|
|
125
|
+
logger.warning("Stopping horizon search due to interruption")
|
|
126
|
+
# An interruption between horizons is just an early stop of
|
|
127
|
+
# continue-scan, not a lost run: a best solution from an earlier
|
|
128
|
+
# horizon is already on disk. This is exactly what happens when an
|
|
129
|
+
# -O2 Savile Row run overruns its per-horizon timelimit and the
|
|
130
|
+
# harness SIGTERMs it - the soft TimeoutError below never fires, so
|
|
131
|
+
# without this salvage the found solution (and its score) is silently
|
|
132
|
+
# discarded and the run reports a bare "-". Recover it the same way
|
|
133
|
+
# the timeout path does; cheap enough to do even on forced termination.
|
|
134
|
+
if best_solution_found and best_solution_found.exists():
|
|
135
|
+
return _create_main_solution_from_best(
|
|
136
|
+
runner, best_solution_found, logger, "before interruption")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
# Create parameter file with horizon constraints
|
|
140
|
+
horizon_param_file = create_horizon_param_file(param_file, output_dir, n, logger, runner)
|
|
141
|
+
if not horizon_param_file:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# Add any extra constraints if callback provided
|
|
145
|
+
if extra_constraints_callback:
|
|
146
|
+
extra_constraints_callback(horizon_param_file, n)
|
|
147
|
+
|
|
148
|
+
logger.info(f"Trying horizon {n}: created {horizon_param_file}")
|
|
149
|
+
|
|
150
|
+
# Call specific horizon logic
|
|
151
|
+
try:
|
|
152
|
+
solution = horizon_callback(n, horizon_param_file)
|
|
153
|
+
if solution:
|
|
154
|
+
logger.info(f"Solution found at horizon {n}")
|
|
155
|
+
return solution
|
|
156
|
+
else:
|
|
157
|
+
logger.info(f"No solution found at horizon {n}")
|
|
158
|
+
# Check if we have an intermediate solution saved from this horizon
|
|
159
|
+
potential_best = Path(SolutionFileNamer.generate_solution_filename(
|
|
160
|
+
runner.summary.instance,
|
|
161
|
+
runner.summary.solver,
|
|
162
|
+
runner.summary.model,
|
|
163
|
+
runner.summary.timestamp,
|
|
164
|
+
tune=runner.summary.tune_slot,
|
|
165
|
+
horizon=n,
|
|
166
|
+
suffix=".best.solution"
|
|
167
|
+
))
|
|
168
|
+
if potential_best.exists():
|
|
169
|
+
best_solution_found = potential_best
|
|
170
|
+
logger.info(f"Updated best solution from horizon {n}")
|
|
171
|
+
except TimeoutError as e:
|
|
172
|
+
# Solver timed out - stop horizon search immediately
|
|
173
|
+
logger.error(f"Horizon search stopped due to timeout at horizon {n}: {e}")
|
|
174
|
+
|
|
175
|
+
# Mark that the run terminated due to timeout
|
|
176
|
+
runner.timeout_termination = True
|
|
177
|
+
|
|
178
|
+
# For timeout-based continue-scan, copy the best solution to the main file
|
|
179
|
+
if best_solution_found and best_solution_found.exists():
|
|
180
|
+
return _create_main_solution_from_best(runner, best_solution_found, logger, "before timeout")
|
|
181
|
+
|
|
182
|
+
# Only re-raise the TimeoutError if we have no solution to return
|
|
183
|
+
logger.error("No solution found before timeout occurred")
|
|
184
|
+
raise
|
|
185
|
+
|
|
186
|
+
# No solution found within horizon limit
|
|
187
|
+
# For timeout-based continue-scan, copy the best solution to the main file
|
|
188
|
+
if best_solution_found and best_solution_found.exists():
|
|
189
|
+
return _create_main_solution_from_best(runner, best_solution_found, logger, "within horizon limit")
|
|
190
|
+
|
|
191
|
+
# The loop ran through every horizon up to the cap without finding a solution
|
|
192
|
+
# and without timing out or being interrupted (those paths return/raise
|
|
193
|
+
# earlier). This is "unable to decide before reaching the horizon limit" -
|
|
194
|
+
# distinct from a genuine failure. Flag it so the orchestrator records
|
|
195
|
+
# final_status 'hlim': a horizon-scanning CP model that exhausts its horizons
|
|
196
|
+
# has NOT proven unsatisfiability (it cannot), it has merely run out of
|
|
197
|
+
# horizons, so this must not be conflated with a planner's proven-unsat.
|
|
198
|
+
runner.horizon_limit_reached = True
|
|
199
|
+
logger.error(f"No solution found within horizon limit ({effective_start}-{effective_stop})")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None) -> Optional[Path]:
|
|
204
|
+
"""
|
|
205
|
+
Create parameter file with base content, ready for horizon-specific constraints.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
param_file: Original parameter file
|
|
209
|
+
output_dir: Output directory
|
|
210
|
+
n: Horizon value
|
|
211
|
+
logger: Logger instance
|
|
212
|
+
runner: SolverRunner instance for consistent naming (optional for backward compatibility)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Path to created parameter file, or None if creation failed
|
|
216
|
+
"""
|
|
217
|
+
n2 = f"{n:02d}" # Zero-padded 2-digit number
|
|
218
|
+
|
|
219
|
+
# Use consistent naming pattern with solution files if runner is provided
|
|
220
|
+
if runner and hasattr(runner, 'summary'):
|
|
221
|
+
instance_name = runner.summary.instance
|
|
222
|
+
solver = runner.summary.solver
|
|
223
|
+
model = runner.summary.model
|
|
224
|
+
timestamp = runner.summary.timestamp
|
|
225
|
+
tune = runner.summary.tune_slot
|
|
226
|
+
tune_str = f"-{tune}" if tune else ""
|
|
227
|
+
horizon_param_file = output_dir / f"{instance_name}-{solver}{tune_str}-{model}-{timestamp}-{n2}.param"
|
|
228
|
+
else:
|
|
229
|
+
# Fallback to old naming for backward compatibility
|
|
230
|
+
horizon_param_file = output_dir / f"{param_file.stem}-{n2}.param"
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
with open(param_file, 'r') as f:
|
|
234
|
+
param_content = f.read()
|
|
235
|
+
|
|
236
|
+
# Remove any existing horizon-related lines
|
|
237
|
+
param_lines = []
|
|
238
|
+
for line in param_content.splitlines():
|
|
239
|
+
# Remove existing horizon constraint lines
|
|
240
|
+
if not any(keyword in line for keyword in ['noSteps', 'maxMoves', 'horizon']):
|
|
241
|
+
param_lines.append(line)
|
|
242
|
+
|
|
243
|
+
# Write base parameter file
|
|
244
|
+
with open(horizon_param_file, 'w') as f:
|
|
245
|
+
f.write('\n'.join(param_lines) + '\n')
|
|
246
|
+
|
|
247
|
+
# Add control file contents if available
|
|
248
|
+
if runner and hasattr(runner, 'summary') and runner.summary.model:
|
|
249
|
+
try:
|
|
250
|
+
control_path = global_state.model_registry.get_control_path(runner.summary.model)
|
|
251
|
+
if control_path and control_path.exists():
|
|
252
|
+
try:
|
|
253
|
+
with open(control_path, 'r') as cf:
|
|
254
|
+
control_content = cf.read()
|
|
255
|
+
# Add control file contents (horizon constraints added by caller after this)
|
|
256
|
+
f.write(control_content)
|
|
257
|
+
if not control_content.endswith('\n'):
|
|
258
|
+
f.write('\n')
|
|
259
|
+
logger.debug(f"Added control file contents from {control_path}")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.warning(f"Failed to read control file {control_path}: {e}")
|
|
262
|
+
except Exception:
|
|
263
|
+
# Model type might not be in registry (e.g., testing unregistered models)
|
|
264
|
+
# This is expected and not an error, so silently continue
|
|
265
|
+
logger.debug(f"Model type '{runner.summary.model}' not found in registry, skipping control file")
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
return horizon_param_file
|
|
269
|
+
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.error(f"Failed to create horizon param file: {e}")
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def calculate_horizon_timeout(start_time: float, original_timeout: Optional[int], n: int, logger: logging.Logger) -> Optional[int]:
|
|
276
|
+
"""
|
|
277
|
+
Calculate remaining timeout for a specific horizon step.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
start_time: Start time of the overall solving process
|
|
281
|
+
original_timeout: Original total timeout in seconds
|
|
282
|
+
n: Current horizon number
|
|
283
|
+
logger: Logger instance for warnings
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Remaining timeout in seconds (minimum 1), or None if no timeout set
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
TimeoutError: If total timeout has already been exceeded
|
|
290
|
+
"""
|
|
291
|
+
if original_timeout is None:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
elapsed_time = time.time() - start_time
|
|
295
|
+
remaining_time = original_timeout - elapsed_time
|
|
296
|
+
|
|
297
|
+
if remaining_time <= 0:
|
|
298
|
+
# We've already exceeded the total timeout
|
|
299
|
+
logger.warning(f"Total timeout of {original_timeout}s exceeded after {elapsed_time:.1f}s")
|
|
300
|
+
raise TimeoutError(f"Total timeout of {original_timeout}s exceeded")
|
|
301
|
+
|
|
302
|
+
# Use remaining time as timeout for this horizon, with minimum of 1 second
|
|
303
|
+
horizon_timeout = max(1, int(remaining_time))
|
|
304
|
+
logger.debug(f"Horizon {n}: {horizon_timeout}s remaining of {original_timeout}s total timeout")
|
|
305
|
+
|
|
306
|
+
return horizon_timeout
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _extract_final_score_for_timeout_solution(runner: SolverRunner, solution_file: Path, logger: logging.Logger) -> None:
|
|
310
|
+
"""Extract score from final timeout solution and add it to the summary."""
|
|
311
|
+
try:
|
|
312
|
+
# Only salvage a score for models whose objective is encoded in the
|
|
313
|
+
# solution (the scored* CP models); others report it via the .obj file.
|
|
314
|
+
if not global_state.model_registry.get_objective_in_solution(runner.summary.model):
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
from .run_summary import PhaseResult
|
|
318
|
+
|
|
319
|
+
if not solution_file.exists():
|
|
320
|
+
logger.debug(f"Solution file {solution_file} does not exist for score extraction")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
# A salvaged timeout solution may not survive Conjure parsing, so ask the
|
|
324
|
+
# consumer for its reported objective straight from the solution file.
|
|
325
|
+
display_score = reported_score_from_timeout_solution(str(solution_file))
|
|
326
|
+
if display_score is not None:
|
|
327
|
+
logger.info(f"Extracted final score for timeout solution: {display_score}")
|
|
328
|
+
|
|
329
|
+
# Create a final stats phase to hold the score
|
|
330
|
+
final_phase = PhaseResult(
|
|
331
|
+
name="final-timeout-solution",
|
|
332
|
+
command=["timeout-best-solution-extraction"],
|
|
333
|
+
duration=0.0,
|
|
334
|
+
success=True,
|
|
335
|
+
output_files=[str(solution_file)],
|
|
336
|
+
error_message="",
|
|
337
|
+
solver_stats={"score": display_score, "solution_found": True, "timeout_solution": True}
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Add the phase to the summary
|
|
341
|
+
runner.summary.phases.append(final_phase)
|
|
342
|
+
|
|
343
|
+
# Trigger score recalculation by re-finalizing
|
|
344
|
+
current_status = runner.summary.final_status
|
|
345
|
+
runner.summary.finalize(current_status)
|
|
346
|
+
|
|
347
|
+
logger.debug(f"Added final score phase, summary score now: {runner.summary.score}")
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
logger.warning(f"Failed to extract final score from timeout solution {solution_file}: {e}")
|