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,261 @@
1
+ """
2
+ Fast Downward solver implementation.
3
+
4
+ This module provides the FastDownwardSolver class for executing the Fast Downward
5
+ PDDL planner with support for both standard and anytime (portfolio) modes.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from .solver_base import Solver
13
+ from .solver_runner import SolverResult
14
+ from .file_management import SolutionFileNamer
15
+ from .utilities import relativize_path
16
+ from .global_state import global_state
17
+
18
+
19
+ class FastDownwardSolver(Solver):
20
+ """Solver implementation for Fast Downward PDDL planner."""
21
+
22
+ def solve(self, param_file: Path, **kwargs) -> Optional[Path]:
23
+ """
24
+ Execute Fast Downward solver for PDDL models.
25
+
26
+ Args:
27
+ param_file: Path to PDDL problem file
28
+ **kwargs: Additional arguments including:
29
+ - model_path: Path to PDDL domain file (required)
30
+ - anytime: Whether to run the anytime portfolio (default: False).
31
+ The runner decides this via registry.is_anytime_tune (the
32
+ active tune's regime), not the model name.
33
+ - instance_file: Original problem instance file
34
+
35
+ Returns:
36
+ Path to solution file if successful, None otherwise
37
+ """
38
+ # Extract required kwargs
39
+ model_path = kwargs.get('model_path')
40
+ if not model_path:
41
+ raise ValueError("model_path is required for Fast Downward solver")
42
+
43
+ anytime = kwargs.get('anytime', False)
44
+ instance_file = kwargs.get('instance_file')
45
+
46
+ # The resolved solver name selects the fd-type config. SymK is a Fast
47
+ # Downward fork, so it flows through this same solver; its config just
48
+ # supplies command:symk and the symbolic search_args.
49
+ solver_name = self.runner.summary.solver or 'fd'
50
+
51
+ # Optional --tune preset from the CLI, held in global_state like the
52
+ # savilerow extra-option flags. A single flag covers both engines;
53
+ # build_fd_command resolves it against the active solver's tune presets
54
+ # (fd: blind/hmax/ff/lmcut/verbose; symk: fw/bw/bd) and falls back to the
55
+ # solver's default_tune if the name is not valid for this solver. With no
56
+ # --tune, honour a model-pinned tune (models.yaml `tune:`) - the same
57
+ # resolution the upf/horizon paths use - so a model can select a working
58
+ # non-default config as its default.
59
+ tune = self._effective_tune()
60
+
61
+ # Build command using global model registry. Raw --options are appended
62
+ # unchanged (the runner does not interpret them).
63
+ cmd = global_state.model_registry.build_fd_command(
64
+ model_path=model_path,
65
+ param_path=str(param_file),
66
+ anytime=anytime,
67
+ timeout=self.runner.timeout,
68
+ tune=tune,
69
+ extra_options=global_state.options,
70
+ solver_name=solver_name
71
+ )
72
+
73
+ # Execute solver
74
+ expected_solution = Path("sas_plan") # FD writes to current directory
75
+
76
+ # Clean up any existing solution files
77
+ for existing in Path.cwd().glob("sas_plan*"):
78
+ try:
79
+ existing.unlink()
80
+ except Exception as e:
81
+ self.logger.warning(f"Failed to remove existing solution file {existing}: {e}")
82
+
83
+ self.logger.info("Running Fast Downward solver")
84
+ result = self.runner.run_phase("solving", cmd, expected_outputs=[str(expected_solution)])
85
+
86
+ if result.interrupted:
87
+ self.logger.warning("Fast Downward solving was interrupted")
88
+ # Check if partial solution exists
89
+ if anytime:
90
+ return self._handle_anytime_solutions(result)
91
+ elif expected_solution.exists():
92
+ return self._handle_single_solution(expected_solution)
93
+ return None
94
+
95
+ if result.timed_out:
96
+ self.logger.info(f"Fast Downward solver timed out after {result.duration:.1f}s")
97
+ if anytime:
98
+ # Anytime portfolio: check if any plans were banked before the budget ran out
99
+ return self._handle_anytime_solutions(result)
100
+ else:
101
+ # For regular PDDL, timeout with no solution is a timeout error
102
+ raise TimeoutError(f"Fast Downward solver timed out after {result.duration:.1f}s")
103
+
104
+ if result.success:
105
+ if anytime:
106
+ return self._handle_anytime_solutions(result)
107
+ else:
108
+ return self._handle_single_solution(expected_solution)
109
+ else:
110
+ # Anytime portfolio tolerance: exit code 34 is expected when some
111
+ # portfolio configurations don't support axioms (as in original run
112
+ # script). This tolerance is specific to the seq-sat-fdss portfolio,
113
+ # currently the only anytime fd tune.
114
+ if (anytime and ("Error: Unexpected exit codes: [34]" in result.stderr or
115
+ "Error: Unexpected exit codes: [34]" in result.stdout)):
116
+ if global_state.verbose:
117
+ self.logger.info("Anytime portfolio encountered expected exit code 34 (unsupported features), continuing with available solutions")
118
+
119
+ solution_file = self._handle_anytime_solutions(result)
120
+ if solution_file is None:
121
+ # No solutions found in anytime mode - this typically happens when the
122
+ # portfolio runs out of time (components get shorter time allocations)
123
+ # Treat this as a timeout case, not a failure (same pattern as UPF solver)
124
+ if global_state.verbose:
125
+ self.logger.info("Anytime portfolio found no solutions within timeout - treating as timeout")
126
+ raise TimeoutError(f"Anytime portfolio timed out after {result.duration:.1f}s with no solutions found")
127
+
128
+ return solution_file
129
+ else:
130
+ self.logger.error(f"Fast Downward solver failed: {result.stderr}")
131
+ return None
132
+
133
+ def _handle_single_solution(self, expected_solution: Path) -> Optional[Path]:
134
+ """Handle single PDDL solution (standard mode)."""
135
+ if expected_solution.exists():
136
+ solution_file = self._generate_solution_file_path(suffix=".1.solution")
137
+
138
+ if self._copy_solution_file(expected_solution, solution_file, move=True):
139
+ # Update the phase output files to reflect the moved file
140
+ solving_phase = self.runner.summary.get_phase("solving")
141
+ if solving_phase:
142
+ expected_solution_rel = str(expected_solution)
143
+ solution_file_rel = str(solution_file)
144
+
145
+ # Replace the expected path with actual path in output_files
146
+ if expected_solution_rel in solving_phase.output_files:
147
+ solving_phase.output_files.remove(expected_solution_rel)
148
+ solving_phase.output_files.append(solution_file_rel)
149
+
150
+ return solution_file
151
+ else:
152
+ return None
153
+ else:
154
+ self.logger.info("No solution found")
155
+ return None
156
+
157
+ def _handle_anytime_solutions(self, result: SolverResult) -> Optional[Path]:
158
+ """Handle multiple PDDL solutions (anytime portfolio mode)."""
159
+ # For interrupted runs, use the best plan tracked during streaming
160
+ if result.interrupted and hasattr(result, 'best_plan_number') and result.best_plan_number > 0:
161
+ plancount = result.best_plan_number
162
+ self.logger.info(f"Anytime portfolio was interrupted, using best plan found so far: {plancount}")
163
+ else:
164
+ # Extract plan count from output by counting "plan manager: found new plan" lines
165
+ plancount = 0
166
+ for line in result.stdout.splitlines():
167
+ if "plan manager: found new plan with cost" in line:
168
+ plancount += 1
169
+
170
+ self.logger.info(f"Anytime portfolio found {plancount} plans")
171
+
172
+ if plancount > 0:
173
+ solution_files = []
174
+ solving_phase = self.runner.summary.get_phase("solving")
175
+
176
+ # For interrupted runs with saved solutions, use those instead of original files
177
+ if (result.interrupted and hasattr(result, 'saved_solutions') and
178
+ result.saved_solutions and result.best_plan_number in result.saved_solutions):
179
+
180
+ self.logger.info(f"Using saved solutions from interrupted run")
181
+ # Copy the best saved solution to final location
182
+ self._bank_solution(
183
+ result.saved_solutions[result.best_plan_number],
184
+ result.best_plan_number, solving_phase, solution_files)
185
+ else:
186
+ # Original logic: move all numbered solution files
187
+ for n in range(1, plancount + 1):
188
+ # First check if we have a saved solution for this plan
189
+ if (hasattr(result, 'saved_solutions') and
190
+ result.saved_solutions and n in result.saved_solutions):
191
+
192
+ self._bank_solution(result.saved_solutions[n], n,
193
+ solving_phase, solution_files)
194
+ else:
195
+ # Original file-based logic for non-interrupted runs
196
+ current_solution = Path(f"sas_plan.{n}")
197
+
198
+ if current_solution.exists():
199
+ self._bank_solution(current_solution, n,
200
+ solving_phase, solution_files,
201
+ move=True)
202
+
203
+ if solution_files:
204
+ self.logger.info(f"Processed {len(solution_files)} solution files")
205
+ # Return the best solution (highest numbered, like original line 630)
206
+ return solution_files[-1]
207
+ else:
208
+ self.logger.error("No solution files could be processed")
209
+ return None
210
+ else:
211
+ self.logger.info("No solutions found")
212
+ return None
213
+
214
+ def _bank_solution(self, source: Path, plan_num: int, solving_phase,
215
+ solution_files: list, move: bool = False) -> None:
216
+ """Bank one anytime plan file under the run's numbered solution name.
217
+
218
+ Generates the ``.{plan_num}.solution`` path, then either moves
219
+ (``move=True``, for the planner's own ``sas_plan.N`` files) or copies
220
+ (``move=False``, for in-memory saved temps, which are unlinked once
221
+ banked) ``source`` onto it, recording the result in ``solution_files``
222
+ and the solving phase's ``output_files``. Failures are logged and
223
+ swallowed, matching the per-plan best-effort banking of the portfolio
224
+ loop; the copy-vs-move split also selects the log/error wording.
225
+ """
226
+ solution_file = SolutionFileNamer.generate_solution_path(
227
+ self.runner.summary.instance,
228
+ self.runner.summary.solver,
229
+ self.runner.summary.model,
230
+ self.runner.summary.timestamp,
231
+ tune=self.runner.summary.tune_slot,
232
+ suffix=f".{plan_num}.solution"
233
+ )
234
+
235
+ try:
236
+ if move:
237
+ os.rename(str(source), str(solution_file))
238
+ else:
239
+ import shutil
240
+ shutil.copy2(str(source), str(solution_file))
241
+ solution_files.append(solution_file)
242
+
243
+ # Update phase output files
244
+ if solving_phase:
245
+ solving_phase.output_files.append(relativize_path(str(solution_file), self.runner.output_dir))
246
+
247
+ if move:
248
+ self.logger.info(f"Moved solution {plan_num} to: {solution_file}")
249
+ else:
250
+ # Clean up temporary file
251
+ try:
252
+ source.unlink()
253
+ except:
254
+ pass # Ignore cleanup errors
255
+ self.logger.info(f"Used saved solution for plan {plan_num}")
256
+
257
+ except Exception as e:
258
+ if move:
259
+ self.logger.error(f"Failed to move solution file {plan_num}: {e}")
260
+ else:
261
+ self.logger.error(f"Failed to copy saved solution file {plan_num}: {e}")