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,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