solver-support 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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}")