solver-support 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solver_support/__init__.py +132 -0
- solver_support/command_builders.py +334 -0
- solver_support/file_management.py +243 -0
- solver_support/global_state.py +62 -0
- solver_support/horizon_utils.py +350 -0
- solver_support/model_registry.py +792 -0
- solver_support/objective.py +92 -0
- solver_support/run_summary.py +338 -0
- solver_support/solver_base.py +140 -0
- solver_support/solver_enhsp.py +101 -0
- solver_support/solver_fd.py +261 -0
- solver_support/solver_horizon.py +634 -0
- solver_support/solver_runner.py +702 -0
- solver_support/solver_stats.py +724 -0
- solver_support/solver_upfplan.py +168 -0
- solver_support/utilities.py +146 -0
- solver_support-0.1.1.dist-info/METADATA +120 -0
- solver_support-0.1.1.dist-info/RECORD +21 -0
- solver_support-0.1.1.dist-info/WHEEL +5 -0
- solver_support-0.1.1.dist-info/licenses/LICENSE +29 -0
- solver_support-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|