PineForge HPO 1.11.0
Native hyperparameter optimization for PineForge strategies
Loading...
Searching...
No Matches
continuation.py
Go to the documentation of this file.
1"""Portable search-space identity and read-only continuation preflight."""
2
3from __future__ import annotations
4
5from .error import HpoError
6
7from dataclasses import dataclass, field, replace
8import hashlib
9import json
10import math
11import re
12from pathlib import Path
13import struct
14import subprocess
15from typing import Any
16
17from .study_spec import (
18 StudySpec,
19 _finite_parameter_cardinality,
20 _fused_grid_value,
21 _reject_duplicate_keys,
22 _reject_json_constant,
23 _parse_objective,
24 _parse_search_space,
25 load_study_spec,
26)
27
28SPACE_HASH_VERSION = 1
29WARM_START_EXIT = 4
30SPACE_EXHAUSTED_EXIT = 5
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61class WarmStartError(HpoError, ValueError):
62 """A parent cannot safely seed the requested study (exit 4)."""
63
64 code = "hpo_warm_start_rejected"
65
66
67class SpaceExhaustedError(HpoError, ValueError):
68 """Every finite parameter vector has already been tried (exit 5)."""
69
70 code = "hpo_space_exhausted"
71
72
73@dataclass(frozen=True)
75 """Validated parent history; no trial execution or billing occurs here."""
76
77 source_sha256: str
78 trials: tuple[dict[str, Any], ...]
79 tried: frozenset[str]
80 completed: int
81 feasible: int
82 next_id: int
83
84
85def recorded_space(study: StudySpec) -> dict[str, Any]:
86 """Return the version-independent declarative space recorded in results."""
87 parameters = {}
88 for name, parameter in study.search_space.items():
89 value: dict[str, Any] = {"kind": parameter.kind}
90 if parameter.kind in {"integer", "real"}:
91 value.update(
92 low=parameter.low,
93 high=parameter.high,
94 step=parameter.step,
95 log=parameter.log,
96 )
97 elif parameter.kind == "categorical":
98 value["choices"] = list(parameter.choices)
99 parameters[name] = value
100 if study.objective.kind != "expression":
101 raise ValueError("space identity requires an expression objective")
102 return {
103 "parameters": parameters,
104 "objective": {
105 "expression": study.objective.expression,
106 "direction": study.objective.direction,
107 "constraints": list(study.objective.constraints),
108 },
109 }
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143def canonical_space(space: dict[str, Any]) -> str:
144 """Return canonical v1 JSON; numeric identities are independent of formatting."""
145 parameters = {}
146 for name, source in space["parameters"].items():
147 kind = source["kind"]
148 parameter = {"kind": kind}
149 if kind in {"integer", "real"}:
150 for key in ("low", "high", "step"):
151 value = source[key]
152 if value is None:
153 parameter[key] = None
154 elif kind == "integer":
155 if type(value) is not int:
156 raise ValueError("expected integer bound/step")
157 parameter[key] = str(value)
158 else:
159 if type(value) not in {int, float}:
160 raise ValueError("expected real bound/step")
161 parameter[key] = _real_bits(value)
162 if type(source["log"]) is not bool:
163 raise ValueError("log must be boolean")
164 parameter["log"] = source["log"]
165 elif kind == "categorical":
166 parameter["choices"] = [_scalar(choice) for choice in source["choices"]]
167 elif kind != "boolean":
168 raise ValueError("unknown parameter kind")
169 parameters[name] = parameter
170 objective = space["objective"]
171 return _dump(
172 {
173 "space_hash_version": SPACE_HASH_VERSION,
174 "parameters": parameters,
175 "objective": {
176 "expression": objective["expression"],
177 "direction": objective["direction"],
178 "constraints": sorted(objective["constraints"]),
179 },
180 }
181 )
182
183
184def space_hash(study: StudySpec) -> str:
185 """SHA-256 of canonical v1 JSON, independent of package versions and pins."""
186 return hashlib.sha256(canonical_space(recorded_space(study)).encode()).hexdigest()
187
188
189def cardinality(study: StudySpec) -> int | None:
190 """Exact uint64 cardinality, or None for continuous/overflowing spaces."""
191 total = 1
192 for name, parameter in study.search_space.items():
193 issues = []
194 count = _finite_parameter_cardinality(parameter, name, issues)
195 if count is None or issues or total > _UINT64_MAX // count:
196 return None
197 total *= count
198 return total
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258@dataclass(frozen=True)
260 """Internal compatibility contract made from an already admitted descriptor."""
261
262 canonical_descriptor: str
263 data_context_sha256: str = field(init=False)
264 dataset_ids: tuple[str, ...] = field(init=False)
265
266
267 def __post_init__(self) -> None:
268 try:
269 descriptor = _loads(self.canonical_descriptor)
270 if (
271 descriptor["schema"] != "pineforge-hpo-data-context/v1"
272 or not isinstance(descriptor["datasets"], dict)
273 or not descriptor["datasets"]
274 or not isinstance(descriptor["bindings"], dict)
275 ):
276 raise ValueError("invalid admitted data context")
277 encoded = _dump(descriptor)
278 object.__setattr__(self, "canonical_descriptor", encoded)
279 object.__setattr__(self, "_descriptor", descriptor)
280 object.__setattr__(
281 self, "dataset_ids", tuple(sorted(descriptor["datasets"]))
282 )
283 object.__setattr__(
284 self,
285 "data_context_sha256",
286 hashlib.sha256(
287 b"pineforge:hpo:data-context:v1\0" + encoded.encode()
288 ).hexdigest(),
289 )
290 except (ValueError, TypeError, KeyError) as error:
291 raise WarmStartError(f"warm-start incompatible: {error}") from error
292
294 self, owner: Any, *, required: bool = False, complete: bool = False
295 ) -> None:
296 if not isinstance(owner, dict):
297 raise ValueError("data context owner must be an object")
298 if required or "data_context_sha256" in owner:
299 if owner.get("data_context_sha256") != self.data_context_sha256data_context_sha256:
300 raise ValueError("missing or differing data_context_sha256")
301 if complete or "dataset_ids" in owner:
302 identifiers = owner.get("dataset_ids")
303 if (
304 not isinstance(identifiers, list)
305 or any(not isinstance(value, str) for value in identifiers)
306 or tuple(sorted(identifiers)) != self.dataset_ids
307 ):
308 raise ValueError("missing or differing dataset_ids")
309 runtime = owner.get("applied_runtime", {})
310 if complete or "data_context" in runtime:
311 if _dump(runtime.get("data_context")) != self.canonical_descriptor:
312 raise ValueError("missing or differing data context descriptor")
313 if (
314 "data_context" in owner
315 and _dump(owner["data_context"]) != self.canonical_descriptor
316 ):
317 raise ValueError("differing data context descriptor")
318 if complete and owner.get("dataset_id") is not None:
319 raise ValueError("bound result cannot declare one global dataset_id")
320
321 def row_identity(self, parameters: Any) -> dict[str, str]:
322 charts = [
323 (title, binding)
324 for title, binding in self._descriptor["bindings"].items()
325 if binding["role"] == "chart"
326 ]
327 if len(charts) != 1:
328 raise ValueError("data context requires one chart binding")
329 title, binding = charts[0]
330 symbol = parameters[title]
331 if not isinstance(symbol, str) or symbol not in binding["choices"]:
332 raise ValueError("trial symbol is outside the admitted binding")
333 dataset_id = binding["datasets"][symbol]
334 if self._descriptor["datasets"][dataset_id]["symbol"] != symbol:
335 raise ValueError("trial binding contradicts dataset symbol")
336 return {
337 "symbol": symbol,
338 "dataset_id": dataset_id,
339 "data_context_sha256": self.data_context_sha256data_context_sha256,
340 }
341
342 def validate_row(self, row: Any, parameters: Any) -> None:
343 self.validate_owner(row, required=True)
344 if any(
345 row.get(name) != value
346 for name, value in self.row_identity(parameters).items()
347 ):
348 raise ValueError(
349 "trial symbol or dataset_id contradicts its parameters and binding"
350 )
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
387 study: StudySpec,
388 path: str | Path,
389 *,
390 data_context: ContinuationDataContext | None = None,
391) -> WarmHistory:
392 """Auto-detect fd-3 JSONL, a complete result object, or its trials array."""
393 try:
394 source = Path(path).expanduser().resolve()
395 content = source.read_bytes()
396 if len(content) > 256 * 1024 * 1024:
397 raise ValueError("warm-start document exceeds 256 MiB")
398 text = content.decode("utf-8")
399 try:
400 document = _loads(text)
401 except json.JSONDecodeError:
402 document = None
403 parent_space = None
404 if isinstance(document, dict) and "trials" in document:
405 _validate_data_context(
406 document, data_context, required=True, complete=data_context is not None
407 )
408 if "tpe_sampler_state" in document:
409 checkpoint = document["tpe_sampler_state"]
410 _validate_sampler_checkpoint(checkpoint)
411 if document.get("trials_out", "all") != "all":
412 raise ValueError("summary/none result is not a complete trial history")
413 own_trials = document["trials"]
414 inherited = document.get("warm_start_trials", [])
415 if "trials_completed" in document and (
416 type(document["trials_completed"]) is not int
417 or document["trials_completed"] != len(own_trials)
418 ):
419 raise ValueError("result does not contain all completed trials")
420 if "warm_start" in document and (
421 type(document["warm_start"]["trials"]) is not int
422 or document["warm_start"]["trials"] != len(inherited)
423 ):
424 raise ValueError("result is missing ancestor trials")
425 trials = inherited + own_trials
426 parent_space = document.get("space")
427 if parent_space is None and "study_spec" in document:
428 embedded = document["study_spec"]
429 issues = []
430 parameters = _parse_search_space(
431 embedded["strategies"][0]["search_space"],
432 "$.strategies[0].search_space",
433 issues,
434 )
435 objective = _parse_objective(embedded["objective"], issues)
436 if issues:
437 raise ValueError("invalid legacy study_spec space or objective")
438 parent_space = recorded_space(
439 replace(
440 study,
441 strategy=replace(study.strategy, search_space=parameters),
442 objective=objective,
443 )
444 )
445 if parent_space is None and "study" in document:
446 parent_spec = Path(document["study"])
447 if not parent_spec.is_absolute():
448 parent_spec = source.parent / parent_spec
449 parent_space = recorded_space(
450 load_study_spec(parent_spec, continuation=True)
451 )
452 elif isinstance(document, list):
453 trials = document
454 else:
455 trials = [_loads(line) for line in text.splitlines() if line.strip()]
456 if not isinstance(trials, list) or not trials:
457 raise ValueError("parent has no trials")
458 expected = canonical_space(recorded_space(study))
459 expected_hash = hashlib.sha256(expected.encode()).hexdigest()
460
461 def verify_hash(owner):
462 version = owner.get("space_hash_version")
463 if version is not None and type(version) is not int:
464 raise ValueError("space_hash_version must be an integer")
465 if (
466 version == SPACE_HASH_VERSION
467 and "space_hash" in owner
468 and owner["space_hash"] != expected_hash
469 ):
470 raise ValueError("recorded space_hash does not match space")
471
472 if parent_space is not None and canonical_space(parent_space) != expected:
473 raise ValueError("search space or objective differs (space_hash)")
474 if parent_space is not None:
475 verify_hash(document)
476 tried = set()
477 identifiers = set()
478 completed = feasible = next_id = 0
479 for trial in trials:
480 space = trial.get("space", parent_space)
481 if space is None:
482 raise ValueError(
483 "missing recorded space; compatibility cannot be proven"
484 )
485 if canonical_space(space) != expected:
486 raise ValueError("search space or objective differs (space_hash)")
487 verify_hash(trial)
488 status = trial["status"]
489 if status not in _STATUSES:
490 raise ValueError(f"unknown trial status: {status}")
491 identifier = trial["trial_id"]
492 if type(identifier) is not int or not 0 <= identifier < _UINT64_MAX:
493 raise ValueError("expected uint64 trial_id with a continuation ID")
494 if identifier in identifiers:
495 raise ValueError("duplicate trial_id")
496 identifiers.add(identifier)
497 next_id = max(next_id, identifier + 1)
498 values = _parameters(study, trial["parameters"])
499 _validate_data_context(trial, data_context, required=True)
500 if data_context is not None:
501 data_context.validate_row(trial, values)
502 tried.add(_dump({name: _scalar(value) for name, value in values.items()}))
503 is_feasible = trial["feasible"]
504 objective = trial["objective"]
505 if type(is_feasible) is not bool or is_feasible != (status == "ok"):
506 raise ValueError("inconsistent status/feasibility/objective")
507 if objective is not None and (
508 type(objective) not in {int, float} or not math.isfinite(objective)
509 ):
510 raise ValueError("objective must be finite or null")
511 for score in trial.get("pruning", {}).get("rung_scores", []):
512 if score is not None and (
513 type(score) not in {int, float} or not math.isfinite(score)
514 ):
515 raise ValueError("invalid pruning rung score")
516 completed += status in {"ok", "constraint_violation"}
517 feasible += is_feasible
518 return WarmHistory(
519 hashlib.sha256(content).hexdigest(),
520 tuple(sorted(trials, key=lambda trial: trial["trial_id"])),
521 frozenset(tried),
522 completed,
523 feasible,
524 next_id,
525 )
526 except (
527 OSError,
528 ValueError,
529 TypeError,
530 KeyError,
531 AttributeError,
532 OverflowError,
533 ) as error:
534 raise WarmStartError(f"warm-start incompatible: {error}") from error
535
536
537def is_binary_warm(path: str | Path) -> bool:
538 """Detect binary input without reading its payload or interpreting JSON."""
539 with Path(path).expanduser().open("rb") as source:
540 return source.read(1) == b"P"
541
542
544 study: StudySpec,
545 path: str | Path,
546 *,
547 native: str | Path | None = None,
548 defer_symbol_feeds: bool = False,
549) -> dict[str, Any]:
550 """Inspect sampler history; binary validation stays in the native mmap loader."""
551 try:
552 if is_binary_warm(path):
553 from .cli import _resolve_native
554
555 process = subprocess.run(
556 [
557 str(_resolve_native(native)),
558 "space-info",
559 "--spec",
560 str(study.spec_path),
561 "--warm-start",
562 str(Path(path).expanduser().resolve()),
563 "--warm-details",
564 *(["--defer-symbol-feeds"] if defer_symbol_feeds else []),
565 ],
566 capture_output=True,
567 text=True,
568 check=False,
569 )
570 if process.returncode:
571 message = process.stderr.strip().split("warm-start incompatible: ")[-1]
572 raise WarmStartError(f"warm-start incompatible: {message}")
573 return json.loads(process.stdout)
574 history = load_warm_start(study, path)
575 count = cardinality(study)
576 return {
577 "cardinality": count,
578 "tried": len(history.tried),
579 "remaining": None if count is None else count - len(history.tried),
580 "space_hash": space_hash(study),
581 "warm_trials": len(history.trials),
582 "next_id": history.next_id,
583 "completed": history.completed,
584 "feasible": history.feasible,
585 "source_sha256": history.source_sha256,
586 }
587 except OSError as error:
588 raise WarmStartError(f"warm-start incompatible: {error}") from error
589
590
592 study: StudySpec,
593 warm_start: str | Path | None = None,
594 *,
595 native: str | Path | None = None,
596) -> dict[str, Any]:
597 """Inspect coverage without compiling a strategy, loading data, or drawing candidates."""
598 count = cardinality(study)
599 tried = (
600 0
601 if warm_start is None
602 else warm_start_metadata(study, warm_start, native=native)["tried"]
603 )
604 return {
605 "cardinality": count,
606 "tried": tried,
607 "remaining": None if count is None else count - tried,
608 "space_hash": space_hash(study),
609 }
None validate_row(self, Any row, Any parameters)
dict[str, str] row_identity(self, Any parameters)
None validate_owner(self, Any owner, *bool required=False, bool complete=False)
WarmHistory load_warm_start(StudySpec study, str|Path path, *ContinuationDataContext|None data_context=None)
dict[str, Any] warm_start_metadata(StudySpec study, str|Path path, *str|Path|None native=None, bool defer_symbol_feeds=False)
bool is_binary_warm(str|Path path)
int|None cardinality(StudySpec study)
dict[str, Any] space_info(StudySpec study, str|Path|None warm_start=None, *str|Path|None native=None)