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,92 @@
1
+ """Objective extraction injection point for the generic solver core.
2
+
3
+ The core records whatever objective value a solver produces, but it has no notion
4
+ of a particular domain's objective -- how it is encoded in a solution file or
5
+ solver output, nor what units it is reported in. A consumer registers extractors
6
+ here, once at setup; each returns the value the core should record (already in the
7
+ consumer's reported units) or None. Until a consumer registers one, every route
8
+ yields None, so the standalone core simply records no score.
9
+
10
+ A consumer typically groups these registrations behind a single
11
+ ``wire_into_core()`` called from its own ``__init__``, so that every entry point
12
+ and test that imports the package configures the core uniformly, and nothing has
13
+ to remember to wire it. See ``docs/injection-api.md`` for a worked example.
14
+ """
15
+
16
+ from typing import Callable, Optional
17
+
18
+
19
+ # --- reported-objective extractors (registered by a consumer) ---
20
+ #
21
+ # The core asks for the *reported* objective of a produced solution at two points
22
+ # but does not know how a domain encodes it: it may live in a solution file, or be
23
+ # printed on solver stdout. A consumer registers an extractor for whichever routes
24
+ # it uses; an unregistered route yields None, so the standalone core simply
25
+ # records no score.
26
+
27
+ _reported_score_from_solution: Optional[Callable[[str], Optional[int]]] = None
28
+ _reported_score_from_stdout: Optional[Callable[[str], Optional[int]]] = None
29
+
30
+
31
+ def set_reported_score_from_solution(fn: Callable[[str], Optional[int]]) -> None:
32
+ """Register ``fn(solution_file) -> Optional[int]`` for the file-based route."""
33
+ global _reported_score_from_solution
34
+ _reported_score_from_solution = fn
35
+
36
+
37
+ def set_reported_score_from_stdout(fn: Callable[[str], Optional[int]]) -> None:
38
+ """Register ``fn(stdout) -> Optional[int]`` for the stdout-based route."""
39
+ global _reported_score_from_stdout
40
+ _reported_score_from_stdout = fn
41
+
42
+
43
+ def reported_score_from_solution(solution_file: str) -> Optional[int]:
44
+ """The reported objective of a solution file, via the registered extractor."""
45
+ if _reported_score_from_solution is None:
46
+ return None
47
+ return _reported_score_from_solution(solution_file)
48
+
49
+
50
+ def reported_score_from_stdout(stdout: str) -> Optional[int]:
51
+ """The reported objective from solver stdout, via the registered extractor."""
52
+ if _reported_score_from_stdout is None:
53
+ return None
54
+ return _reported_score_from_stdout(stdout)
55
+
56
+
57
+ # A salvaged timeout solution gets its own extractor: it may need a parser that
58
+ # does not rely on the normal (Conjure) solution loader, which can fail on a
59
+ # solution the solver was killed mid-write of.
60
+ _reported_score_from_timeout_solution: Optional[Callable[[str], Optional[int]]] = None
61
+
62
+
63
+ def set_reported_score_from_timeout_solution(fn: Callable[[str], Optional[int]]) -> None:
64
+ """Register ``fn(solution_file) -> Optional[int]`` for a salvaged timeout solution."""
65
+ global _reported_score_from_timeout_solution
66
+ _reported_score_from_timeout_solution = fn
67
+
68
+
69
+ def reported_score_from_timeout_solution(solution_file: str) -> Optional[int]:
70
+ """The reported objective of a salvaged timeout solution, via the extractor."""
71
+ if _reported_score_from_timeout_solution is None:
72
+ return None
73
+ return _reported_score_from_timeout_solution(solution_file)
74
+
75
+
76
+ # The raw search objective is separate from the reported-score extractors above:
77
+ # it is the value the horizon scan compares between horizons, so it stays in model
78
+ # units and is never scaled for display.
79
+ _raw_objective_from_solution: Optional[Callable[[str], Optional[int]]] = None
80
+
81
+
82
+ def set_raw_objective_from_solution(fn: Callable[[str], Optional[int]]) -> None:
83
+ """Register ``fn(solution_file) -> Optional[int]`` for the raw search objective."""
84
+ global _raw_objective_from_solution
85
+ _raw_objective_from_solution = fn
86
+
87
+
88
+ def raw_objective_from_solution(solution_file: str) -> Optional[int]:
89
+ """The raw (unscaled) search objective of a solution file, via the extractor."""
90
+ if _raw_objective_from_solution is None:
91
+ return None
92
+ return _raw_objective_from_solution(solution_file)
@@ -0,0 +1,338 @@
1
+ """
2
+ Data structures for representing structured run summaries and phase results.
3
+
4
+ These classes provide a machine-readable representation of puzzle solver runs,
5
+ including timing information, success status, and solver-specific statistics.
6
+ """
7
+
8
+ from dataclasses import dataclass, field, asdict, fields
9
+ from datetime import datetime
10
+ from typing import List, Dict, Any, Optional, Tuple, Type
11
+ import json
12
+ from pathlib import Path
13
+
14
+
15
+ # --- consumer-owned fields the core does not interpret ---
16
+ #
17
+ # The core owns the *shape* of a run record, but a consumer often has a datum
18
+ # that belongs on the run and that the core has no concept of - a domain
19
+ # cross-check, a provenance tag. Such a field must not be declared on the core's
20
+ # dataclasses, or the "domain-agnostic" record grows a consumer's vocabulary (as
21
+ # a `canonical_score`, naming one consumer's replay scorer, once did). It goes
22
+ # in ``extra`` instead.
23
+ #
24
+ # ``extra`` is *flattened* on the way out and re-gathered on the way in, so the
25
+ # JSON is exactly as it would have been with a declared field. That keeps every
26
+ # reader that indexes the saved summary by key working unchanged, and is what
27
+ # makes moving a field into ``extra`` a no-op on disk. The gathering half also
28
+ # means a summary written by a *different* consumer loads here instead of
29
+ # raising TypeError on its unknown keys - which matters now more than one
30
+ # consumer writes these files.
31
+
32
+
33
+ def _flatten_extra(data: Dict[str, Any]) -> Dict[str, Any]:
34
+ """Fold ``extra`` up into the top level of a serialised record.
35
+
36
+ A core field always wins a name collision (``setdefault``), so a consumer
37
+ cannot shadow the record's own vocabulary - and a field later *removed* from
38
+ the core is transparently served from ``extra`` instead.
39
+ """
40
+ extra = data.pop('extra', None) or {}
41
+ for key, value in extra.items():
42
+ data.setdefault(key, value)
43
+ return data
44
+
45
+
46
+ def _split_known(cls: Type, data: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
47
+ """Partition ``data`` into (keys declared on ``cls``, everything else)."""
48
+ known = {f.name for f in fields(cls)}
49
+ declared = {k: v for k, v in data.items() if k in known}
50
+ extra = {k: v for k, v in data.items() if k not in known}
51
+ return declared, extra
52
+
53
+
54
+ @dataclass
55
+ class PhaseResult:
56
+ """Individual phase (translation/solving/visualisation) results."""
57
+
58
+ name: str # Phase name: "translation", "solving", "visualisation"
59
+ command: List[str] = field(default_factory=list) # Command that was run
60
+ duration: float = 0.0 # Execution time in seconds
61
+ success: bool = False # Whether phase completed successfully
62
+ output_files: List[str] = field(default_factory=list) # Files created by this phase
63
+ error_message: str = "" # Error details if failed
64
+ peak_memory_kb: Optional[int] = None # Peak memory usage in KB
65
+ solver_stats: Dict[str, Any] = field(default_factory=dict) # Solver-specific statistics
66
+ warnings: List[str] = field(default_factory=list) # Warning messages from stderr
67
+ # Why the phase ended, kept alongside the bare `success` flag so the outcome
68
+ # can be reconstructed from a saved summary. `success` conflates the three
69
+ # failure kinds SolverResult distinguishes, and the distinction matters to a
70
+ # reader of the JSON: a timed-out baseline is a result, whereas a launch
71
+ # failure (missing / non-executable binary) is an infrastructure error that
72
+ # aborts a horizon scan. None/False means "not recorded" - phases built by
73
+ # hand, or by the error path of run_phase, leave them at their defaults.
74
+ returncode: Optional[int] = None # Process return code (None if never launched)
75
+ timed_out: bool = False # Killed for exceeding the timeout
76
+ launch_failed: bool = False # Executable could not be started
77
+ # Consumer-owned per-phase fields the core does not interpret. Most phase
78
+ # extras belong in `solver_stats` (that is what it is for); this is for a
79
+ # datum that is about the phase itself rather than about the solver's run.
80
+ extra: Dict[str, Any] = field(default_factory=dict)
81
+
82
+ def to_dict(self) -> Dict[str, Any]:
83
+ """Convert to dictionary for JSON serialization (``extra`` flattened in)."""
84
+ return _flatten_extra(asdict(self))
85
+
86
+ @classmethod
87
+ def from_dict(cls, data: Dict[str, Any]) -> 'PhaseResult':
88
+ """Build from a serialised phase, gathering unknown keys into ``extra``."""
89
+ declared, extra = _split_known(cls, data)
90
+ phase = cls(**declared)
91
+ phase.extra.update(extra)
92
+ return phase
93
+
94
+
95
+ # Phases that translate / model / visualise never "find a solution". Their
96
+ # stdout can still trip the generic solution-indicator parse in solver_stats
97
+ # (e.g. the word "optimal" in a prob2any .param comment), which would otherwise
98
+ # latch a spurious solution_found=True onto the run. Only solving phases may set
99
+ # it. (This is a known summary-generation imperfection.)
100
+ _NON_SOLVING_PHASES = frozenset({"translation", "modelling", "visualisation"})
101
+
102
+ # Best-effort post-processing phases. The runner runs these after a solution is
103
+ # already in hand and explicitly treats their failure as non-fatal, so their
104
+ # success must NOT decide the overall run success. (Without this, a broken
105
+ # visualiser or .sol export would silently flip a solved run to success=False.)
106
+ _OPTIONAL_PHASES = frozenset({"visualisation", "export"})
107
+
108
+ # final_status values the orchestrator uses to mean "this run did not produce a
109
+ # solution / could not run". On these, overall success is False regardless of
110
+ # whether each individual subprocess exited 0 — a solver that runs cleanly but
111
+ # yields no solution (all horizons UNSAT, or a planner that returns no plan) is
112
+ # not a success. `final_status` is the orchestrator's verdict; `all(phase.success)`
113
+ # only knows that subprocesses didn't crash, so the verdict wins. See
114
+ # [[upf-noplan-success-reporting]] and docs/server-harness.md.
115
+ # 'hlim' (horizon scan exhausted its budget without deciding) is a no-solution
116
+ # outcome too, so it also forces success False; it is kept as a distinct status
117
+ # string so consumers can tell "ran out of horizons" from a genuine failure.
118
+ _FAILURE_STATUSES = frozenset({"failed", "launch-failed", "hlim"})
119
+
120
+
121
+ @dataclass
122
+ class RunSummary:
123
+ """Complete puzzle run summary with structured timing data."""
124
+
125
+ instance: str = "" # Problem instance name
126
+ model: str = "" # Model type used
127
+ solver: str = "" # Solver used
128
+ tune_slot: str = "" # Filename config slot (defopt / tune / tune+shorthash)
129
+ options: str = "" # Readable config label (defopt / tune / tune+O2), for grouping/plots
130
+ timestamp: str = "" # Execution timestamp
131
+ command_line: str = "" # Original command line used
132
+ output_dir: str = "" # Output directory for results
133
+ start_time: Optional[datetime] = None # Run start time
134
+ end_time: Optional[datetime] = None # Run completion time
135
+ total_duration: float = 0.0 # Total execution time in seconds
136
+ success: bool = False # Overall success
137
+ solution_found: bool = False # Whether solution was found
138
+ final_status: str = "" # Final status message
139
+ score: Optional[int] = None # Total score the model reported (scored/upf models only)
140
+ startat: Optional[int] = None # Starting horizon for Savile Row models (if specified)
141
+ stopat: Optional[int] = None # Highest horizon tried, if the default cap (100) was overridden
142
+ objbound: Optional[int] = None # Objective bound constraint for optimization models (if specified)
143
+ phases: List[PhaseResult] = field(default_factory=list) # Individual phase results
144
+ # Consumer-owned fields the core does not interpret, flattened into the saved
145
+ # JSON so they are indistinguishable on disk from a declared field. This is
146
+ # where a domain datum belongs - e.g. a `canonical_score`, the score of an
147
+ # independent replay of the solution, which cross-checks the solver-reported
148
+ # `score` and which the core has no way to compute or interpret.
149
+ extra: Dict[str, Any] = field(default_factory=dict)
150
+
151
+ def add_phase(self, phase: PhaseResult) -> None:
152
+ """Add a phase result and update summary statistics."""
153
+ self.phases.append(phase)
154
+ self.total_duration += phase.duration
155
+
156
+ # Update solution found status (solving phases only: see _NON_SOLVING_PHASES)
157
+ if (phase.name not in _NON_SOLVING_PHASES
158
+ and phase.solver_stats.get('solution_found', False)):
159
+ self.solution_found = True
160
+
161
+ @property
162
+ def required_phases(self) -> List[PhaseResult]:
163
+ """Phases that decide overall success (excludes best-effort ones).
164
+
165
+ Best-effort post-processing (visualisation/.sol export) is non-fatal
166
+ and must not fail an otherwise-solved run, so it is filtered out here.
167
+ """
168
+ return [p for p in self.phases if p.name not in _OPTIONAL_PHASES]
169
+
170
+ def update_required_success(self) -> None:
171
+ """Set success from the required phases as a running interim value.
172
+
173
+ Applied by run_phase after each phase; finalize() later re-derives
174
+ success authoritatively, honouring final_status and timeout solutions.
175
+ Note all() over no required phases is True (matched by finalize's
176
+ explicit bool(required) guard for the authoritative pass).
177
+ """
178
+ self.success = all(p.success for p in self.required_phases)
179
+
180
+ def finalize(self, final_status: str = "done") -> None:
181
+ """Finalize the summary with end time and overall success status."""
182
+ self.end_time = datetime.now()
183
+ self.final_status = final_status
184
+
185
+ # Check if we have a successful timeout-based solution phase: an anytime
186
+ # run that found a solution before the budget ran out still succeeded.
187
+ has_timeout_solution = any(
188
+ phase.name == "final-timeout-solution" and phase.success
189
+ and 'timeout_solution' in phase.solver_stats
190
+ and phase.solver_stats['timeout_solution']
191
+ for phase in self.phases
192
+ )
193
+
194
+ if final_status in _FAILURE_STATUSES:
195
+ # The orchestrator already judged this run a failure (no solution
196
+ # produced, or the tool could not be launched). Don't let cleanly
197
+ # exiting subprocesses (e.g. all-UNSAT horizons) override that — a
198
+ # run that found no solution is not a success.
199
+ self.success = False
200
+ elif self.phases:
201
+ # Overall success is decided by the REQUIRED phases only: best-effort
202
+ # post-processing (visualisation/.sol export) is non-fatal and must
203
+ # not fail an otherwise-solved run. A valid timeout solution also
204
+ # counts (the final solving horizon itself shows as timed-out).
205
+ required = self.required_phases
206
+ self.success = (bool(required)
207
+ and all(p.success for p in required)) or has_timeout_solution
208
+ else:
209
+ self.success = False
210
+
211
+ # Extract the highest score from any phase that has one
212
+ scores = []
213
+ for phase in self.phases:
214
+ if 'score' in phase.solver_stats and phase.solver_stats['score'] is not None:
215
+ scores.append(phase.solver_stats['score'])
216
+
217
+ # Set summary score to the maximum found, or None if no scores found
218
+ self.score = max(scores) if scores else None
219
+
220
+ def get_phase(self, name: str) -> Optional[PhaseResult]:
221
+ """Get a phase by name."""
222
+ for phase in self.phases:
223
+ if phase.name == name:
224
+ return phase
225
+ return None
226
+
227
+ def get_solving_time(self) -> float:
228
+ """Get the duration of the solving phase."""
229
+ solving_phase = self.get_phase("solving")
230
+ return solving_phase.duration if solving_phase else 0.0
231
+
232
+ def get_translation_time(self) -> float:
233
+ """Get the duration of the translation phase."""
234
+ translation_phase = self.get_phase("translation")
235
+ return translation_phase.duration if translation_phase else 0.0
236
+
237
+ def to_dict(self) -> Dict[str, Any]:
238
+ """Convert to dictionary for JSON serialization."""
239
+ data = asdict(self)
240
+
241
+ # Consumer-owned fields sit at the top level, exactly where a declared
242
+ # field would be, so moving a field into `extra` does not change the
243
+ # file. Phases get the same treatment (asdict has already flattened them
244
+ # to plain dicts, `extra` key and all).
245
+ data = _flatten_extra(data)
246
+ data['phases'] = [_flatten_extra(p) for p in data['phases']]
247
+
248
+ # Convert datetime objects to ISO format strings
249
+ if data['start_time']:
250
+ data['start_time'] = self.start_time.isoformat()
251
+ if data['end_time']:
252
+ data['end_time'] = self.end_time.isoformat()
253
+
254
+ # Drop the filename tune slot when it is the empty default: it is
255
+ # redundant with the filename itself and only clutters older-style
256
+ # summaries that never set it.
257
+ if not data.get('tune_slot'):
258
+ data.pop('tune_slot', None)
259
+ # Same for the readable options label - absent means "reconstruct from
260
+ # the command line" for consumers, keeping older summaries unchanged.
261
+ if not data.get('options'):
262
+ data.pop('options', None)
263
+ # Remove startat, stopat and objbound if they are None (defaults)
264
+ if data.get('startat') is None:
265
+ del data['startat']
266
+ if data.get('stopat') is None:
267
+ del data['stopat']
268
+ if data.get('objbound') is None:
269
+ del data['objbound']
270
+ # Consumer fields need no such pruning: `extra` holds only what a
271
+ # consumer actually put there, so an uncomputed one is simply absent.
272
+
273
+ return data
274
+
275
+ def to_json(self, indent: int = 2) -> str:
276
+ """Convert to JSON string."""
277
+ return json.dumps(self.to_dict(), indent=indent)
278
+
279
+ def save(self, path: Path) -> None:
280
+ """Save summary to file."""
281
+ path.write_text(self.to_json())
282
+
283
+ @classmethod
284
+ def from_dict(cls, data: Dict[str, Any]) -> 'RunSummary':
285
+ """Create RunSummary from dictionary.
286
+
287
+ Keys the core does not declare are gathered into ``extra`` rather than
288
+ rejected, so a summary written by another consumer - or by a later
289
+ version that added a field - loads here instead of raising TypeError.
290
+ """
291
+ # Make a copy to avoid modifying the original data
292
+ data = data.copy()
293
+
294
+ # Convert datetime strings back to datetime objects
295
+ if data.get('start_time'):
296
+ data['start_time'] = datetime.fromisoformat(data['start_time'])
297
+ if data.get('end_time'):
298
+ data['end_time'] = datetime.fromisoformat(data['end_time'])
299
+
300
+ # Convert phase dictionaries to PhaseResult objects
301
+ phases_data = data.pop('phases', [])
302
+ phases = [PhaseResult.from_dict(phase_data) for phase_data in phases_data]
303
+
304
+ declared, extra = _split_known(cls, data)
305
+ summary = cls(**declared)
306
+ summary.extra.update(extra)
307
+ summary.phases = phases
308
+ return summary
309
+
310
+ @classmethod
311
+ def from_json(cls, json_str: str) -> 'RunSummary':
312
+ """Create RunSummary from JSON string."""
313
+ data = json.loads(json_str)
314
+ return cls.from_dict(data)
315
+
316
+ @classmethod
317
+ def load(cls, path: Path) -> 'RunSummary':
318
+ """Load summary from file."""
319
+ json_str = path.read_text()
320
+ return cls.from_json(json_str)
321
+
322
+
323
+ def create_run_summary(instance: str, model: str, solver: str = "", timestamp: str = None,
324
+ output_dir: str = "") -> RunSummary:
325
+ """Create a new RunSummary with basic information filled in."""
326
+ summary = RunSummary()
327
+ summary.instance = instance
328
+ summary.model = model
329
+ summary.solver = solver
330
+ summary.timestamp = timestamp
331
+ summary.output_dir = output_dir
332
+ summary.start_time = datetime.now()
333
+ return summary
334
+
335
+
336
+ def create_phase_result(name: str, command: List[str]) -> PhaseResult:
337
+ """Create a new PhaseResult with basic information filled in."""
338
+ return PhaseResult(name=name, command=command.copy())
@@ -0,0 +1,140 @@
1
+ """
2
+ Base solver class for puzzle solvers.
3
+
4
+ This module contains the abstract Solver class that all puzzle solvers inherit from.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from pathlib import Path
9
+ from typing import Optional
10
+ import os
11
+ import shutil
12
+
13
+ from .file_management import SolutionFileNamer
14
+ from .utilities import relativize_path
15
+ from .global_state import global_state
16
+
17
+
18
+ class Solver(ABC):
19
+ """Abstract base class for puzzle solvers."""
20
+
21
+ def __init__(self, solver_runner):
22
+ """
23
+ Initialize solver with a SolverRunner instance.
24
+
25
+ Args:
26
+ solver_runner: SolverRunner instance that provides logging and phase tracking
27
+ """
28
+ self.runner = solver_runner
29
+ self.logger = solver_runner.logger
30
+
31
+ def _effective_tune(self) -> Optional[str]:
32
+ """The effective ``--tune`` for this run.
33
+
34
+ An explicit CLI ``--tune`` (held in ``global_state``) wins; otherwise
35
+ the running model's pinned ``default`` (``models.yaml`` ``tune:``), or
36
+ None if neither applies. This is the single resolution shared by the
37
+ fd / enhsp / upfplan / horizon backends - what picks the search config,
38
+ engine, or sat_args when the user passes no ``--tune``. Defensive:
39
+ tolerates a ``global_state`` without ``tune`` and an unknown model.
40
+ """
41
+ explicit = getattr(global_state, 'tune', None)
42
+ if explicit:
43
+ return explicit
44
+ try:
45
+ return global_state.model_registry.get_model_default_tune(
46
+ self.runner.summary.model)
47
+ except Exception:
48
+ return None
49
+
50
+ @abstractmethod
51
+ def solve(self, param_file: Path, **kwargs) -> Optional[Path]:
52
+ """
53
+ Execute the solver on the given parameter file.
54
+
55
+ Args:
56
+ param_file: Path to parameter file
57
+ **kwargs: Additional solver-specific arguments
58
+
59
+ Returns:
60
+ Path to solution file if successful, None otherwise
61
+
62
+ Note:
63
+ Solver operates in current working directory after chdir.
64
+ All file paths should be relative to current directory.
65
+ """
66
+ pass
67
+
68
+ def _generate_solution_file_path(self, suffix: str = ".solution") -> Path:
69
+ """
70
+ Generate a solution file path using consistent naming.
71
+
72
+ Args:
73
+ suffix: Optional suffix to append to the filename
74
+
75
+ Returns:
76
+ Path to the solution file (relative to current directory)
77
+ """
78
+ instance_name = self.runner.summary.instance
79
+ solver = self.runner.summary.solver
80
+ model = self.runner.summary.model
81
+ timestamp = self.runner.summary.timestamp
82
+
83
+ return SolutionFileNamer.generate_solution_path(
84
+ instance_name, solver, model, timestamp,
85
+ tune=self.runner.summary.tune_slot, suffix=suffix
86
+ )
87
+
88
+ def _update_phase_output_files(self, phase_name: str, file_path: Path) -> None:
89
+ """
90
+ Update a phase's output files list with a new file.
91
+
92
+ Args:
93
+ phase_name: Name of the phase to update
94
+ file_path: Path to the file to add to the phase outputs
95
+ """
96
+ phase = self.runner.summary.get_phase(phase_name)
97
+ if phase:
98
+ file_rel = relativize_path(str(file_path), self.runner.output_dir)
99
+ phase.output_files.append(file_rel)
100
+
101
+ def _handle_timeout_result(self, result, solver_name: str) -> None:
102
+ """
103
+ Handle timeout results consistently across solvers.
104
+
105
+ Args:
106
+ result: SolverResult instance
107
+ solver_name: Name of the solver for error messages
108
+
109
+ Raises:
110
+ TimeoutError: If the result indicates a timeout
111
+ """
112
+ if result.timed_out:
113
+ duration = getattr(result, 'duration', 0)
114
+ self.logger.warning(f"{solver_name} timed out after {duration:.1f}s")
115
+ raise TimeoutError(f"{solver_name} timed out after {duration:.1f}s")
116
+
117
+ def _copy_solution_file(self, source: Path, destination: Path, move: bool = False) -> bool:
118
+ """
119
+ Copy or move a solution file with error handling.
120
+
121
+ Args:
122
+ source: Source file path
123
+ destination: Destination file path
124
+ move: If True, move the file instead of copying
125
+
126
+ Returns:
127
+ True if successful, False otherwise
128
+ """
129
+ try:
130
+ if move:
131
+ os.rename(str(source), str(destination))
132
+ self.logger.info(f"Moved solution to: {destination}")
133
+ else:
134
+ shutil.copy2(str(source), str(destination))
135
+ self.logger.info(f"Copied solution to: {destination}")
136
+ return True
137
+ except Exception as e:
138
+ action = "move" if move else "copy"
139
+ self.logger.error(f"Failed to {action} solution file: {e}")
140
+ return False
@@ -0,0 +1,101 @@
1
+ """
2
+ ENHSP solver implementation.
3
+
4
+ This module provides the EnhspSolver class for executing the ENHSP forward-search
5
+ PDDL planner directly (running it via the Unified Planning Framework instead is
6
+ the separate upfenhsp solver). ENHSP takes the domain/problem as -o/-f and writes
7
+ its plan to a -sp file in the same `(action args)` form as Fast Downward's
8
+ sas_plan, so the PDDLSolutionParser consumes the result unchanged.
9
+ """
10
+
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .solver_base import Solver
15
+ from .global_state import global_state
16
+
17
+
18
+ class EnhspSolver(Solver):
19
+ """Solver implementation for the ENHSP PDDL planner."""
20
+
21
+ def solve(self, param_file: Path, **kwargs) -> Optional[Path]:
22
+ """
23
+ Execute ENHSP for a PDDL model.
24
+
25
+ Args:
26
+ param_file: Path to the PDDL problem file.
27
+ **kwargs: Additional arguments including:
28
+ - model_path: Path to the PDDL domain file (required).
29
+
30
+ Returns:
31
+ Path to the solution file if a plan was found, None otherwise.
32
+ """
33
+ model_path = kwargs.get('model_path')
34
+ if not model_path:
35
+ raise ValueError("model_path is required for the ENHSP solver")
36
+
37
+ solver_name = self.runner.summary.solver or 'enhsp'
38
+
39
+ # Optional --tune preset from the CLI, held in global_state like the other
40
+ # backends. build_enhsp_command resolves it against the active solver's
41
+ # planner presets (hmrp/hadd/hrmax/blind) and falls back to default_tune.
42
+ # With no --tune, honour a model-pinned tune (models.yaml `tune:`, e.g.
43
+ # pddlscorednoaxiom pins blind because enhsp's default hmrp/hrmax heuristics
44
+ # NPE on its conditional effects) - the same resolution the upf/horizon
45
+ # paths use.
46
+ tune = self._effective_tune()
47
+
48
+ # ENHSP writes its plan straight to the -sp path, so point it at the final
49
+ # solution file name (the same `.1.solution` convention as the fd solver).
50
+ solution_file = self._generate_solution_file_path(suffix=".1.solution")
51
+
52
+ # Clean up any stale plan from a previous run.
53
+ if solution_file.exists():
54
+ try:
55
+ solution_file.unlink()
56
+ except Exception as e:
57
+ self.logger.warning(f"Failed to remove existing solution file {solution_file}: {e}")
58
+
59
+ cmd = global_state.model_registry.build_enhsp_command(
60
+ model_path=model_path,
61
+ param_path=str(param_file),
62
+ plan_path=str(solution_file),
63
+ tune=tune,
64
+ extra_options=global_state.options,
65
+ solver_name=solver_name
66
+ )
67
+
68
+ self.logger.info("Running ENHSP solver")
69
+ # ENHSP writes straight to the final path, so the phase output is recorded
70
+ # in _finalise (only when the file actually exists) rather than via
71
+ # run_phase's expected_outputs, which would list it even on a failed parse.
72
+ result = self.runner.run_phase("solving", cmd)
73
+
74
+ if result.timed_out:
75
+ # No internal time limit outside anytime mode: the runner terminated
76
+ # ENHSP mid-search, so no plan was written - a genuine timeout.
77
+ self._handle_timeout_result(result, "ENHSP")
78
+
79
+ if result.interrupted:
80
+ self.logger.warning("ENHSP solving was interrupted")
81
+ return self._finalise(solution_file)
82
+
83
+ if result.success:
84
+ return self._finalise(solution_file)
85
+
86
+ # ENHSP exits non-zero when it cannot parse the model (e.g. the axiom
87
+ # `pddl` domain, which it does not support) or fails to plan. A plan may
88
+ # still have been written on some paths, so salvage it if present.
89
+ salvaged = self._finalise(solution_file)
90
+ if salvaged is None:
91
+ self.logger.error(f"ENHSP solver failed: {result.stderr}")
92
+ return salvaged
93
+
94
+ def _finalise(self, solution_file: Path) -> Optional[Path]:
95
+ """Register and return the plan file if ENHSP wrote one, else None."""
96
+ if solution_file.exists():
97
+ self._update_phase_output_files("solving", solution_file)
98
+ self.logger.info(f"ENHSP wrote solution: {solution_file}")
99
+ return solution_file
100
+ self.logger.info("No solution found")
101
+ return None