osprey-optimizer 2026.11.0__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.
Files changed (80) hide show
  1. osprey_optimizer/__init__.py +16 -0
  2. osprey_optimizer/_version.py +24 -0
  3. osprey_optimizer/api/__init__.py +12 -0
  4. osprey_optimizer/api/dependencies.py +58 -0
  5. osprey_optimizer/api/environment_ops.py +294 -0
  6. osprey_optimizer/api/job_control.py +416 -0
  7. osprey_optimizer/api/job_status.py +71 -0
  8. osprey_optimizer/api/job_tracker.py +524 -0
  9. osprey_optimizer/api/main.py +289 -0
  10. osprey_optimizer/api/manual_authorization.py +210 -0
  11. osprey_optimizer/api/optimization_lifecycle.py +329 -0
  12. osprey_optimizer/api/optimization_status.py +157 -0
  13. osprey_optimizer/api/owner.py +96 -0
  14. osprey_optimizer/api/routers/__init__.py +0 -0
  15. osprey_optimizer/api/routers/environments.py +218 -0
  16. osprey_optimizer/api/routers/jobs.py +306 -0
  17. osprey_optimizer/api/routers/models.py +64 -0
  18. osprey_optimizer/api/routers/optimization.py +39 -0
  19. osprey_optimizer/api/routers/results.py +245 -0
  20. osprey_optimizer/api/routers/run_config.py +142 -0
  21. osprey_optimizer/api/runner.py +58 -0
  22. osprey_optimizer/api/security.py +53 -0
  23. osprey_optimizer/api/start_gates.py +61 -0
  24. osprey_optimizer/api/summary.py +286 -0
  25. osprey_optimizer/config/__init__.py +0 -0
  26. osprey_optimizer/config/logging.py +38 -0
  27. osprey_optimizer/environments/__init__.py +1 -0
  28. osprey_optimizer/environments/connector_env.py +413 -0
  29. osprey_optimizer/environments/interface.py +404 -0
  30. osprey_optimizer/environments/spec.py +231 -0
  31. osprey_optimizer/environments/write_authority.py +108 -0
  32. osprey_optimizer/events.py +177 -0
  33. osprey_optimizer/models/__init__.py +5 -0
  34. osprey_optimizer/models/api.py +261 -0
  35. osprey_optimizer/models/config.py +254 -0
  36. osprey_optimizer/models/enums.py +98 -0
  37. osprey_optimizer/models/exceptions.py +149 -0
  38. osprey_optimizer/models/process_scope.py +23 -0
  39. osprey_optimizer/models/protocols.py +417 -0
  40. osprey_optimizer/models/state.py +44 -0
  41. osprey_optimizer/models/testing.py +28 -0
  42. osprey_optimizer/models/types.py +75 -0
  43. osprey_optimizer/optimization/__init__.py +1 -0
  44. osprey_optimizer/optimization/best_point.py +113 -0
  45. osprey_optimizer/optimization/coercion.py +45 -0
  46. osprey_optimizer/optimization/evaluation.py +173 -0
  47. osprey_optimizer/optimization/generators/__init__.py +11 -0
  48. osprey_optimizer/optimization/generators/baxus.py +631 -0
  49. osprey_optimizer/optimization/runner.py +354 -0
  50. osprey_optimizer/optimization/state.py +401 -0
  51. osprey_optimizer/optimization/surrogate/__init__.py +5 -0
  52. osprey_optimizer/optimization/surrogate/constructor.py +235 -0
  53. osprey_optimizer/optimization/surrogate/wrapper.py +162 -0
  54. osprey_optimizer/optimization/types.py +29 -0
  55. osprey_optimizer/optimization/xopt_backend.py +591 -0
  56. osprey_optimizer/results.py +601 -0
  57. osprey_optimizer/services/__init__.py +0 -0
  58. osprey_optimizer/services/bounds_resolver.py +167 -0
  59. osprey_optimizer/services/draft_resolution.py +108 -0
  60. osprey_optimizer/services/execution.py +567 -0
  61. osprey_optimizer/services/orchestration.py +286 -0
  62. osprey_optimizer/services/plugin_registry.py +221 -0
  63. osprey_optimizer/services/result_pipeline.py +75 -0
  64. osprey_optimizer/services/write_authorization.py +500 -0
  65. osprey_optimizer/utils/__init__.py +0 -0
  66. osprey_optimizer/utils/cancellation.py +220 -0
  67. osprey_optimizer/utils/control.py +35 -0
  68. osprey_optimizer/utils/interval_thread.py +78 -0
  69. osprey_optimizer/utils/job_keys.py +57 -0
  70. osprey_optimizer/utils/json_safe.py +35 -0
  71. osprey_optimizer/utils/log_capture.py +64 -0
  72. osprey_optimizer/utils/numeric.py +14 -0
  73. osprey_optimizer/utils/process.py +88 -0
  74. osprey_optimizer/utils/redis_client.py +95 -0
  75. osprey_optimizer/utils/redis_state_store.py +149 -0
  76. osprey_optimizer/utils/serialization.py +24 -0
  77. osprey_optimizer-2026.11.0.dist-info/METADATA +52 -0
  78. osprey_optimizer-2026.11.0.dist-info/RECORD +80 -0
  79. osprey_optimizer-2026.11.0.dist-info/WHEEL +4 -0
  80. osprey_optimizer-2026.11.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,16 @@
1
+ """Optimizer service: Xopt-driven tuning jobs behind a FastAPI job API.
2
+
3
+ Ported from tuning_scripts at commit 1bac27a1.
4
+
5
+ The base install carries the contracts: ``models`` and ``environments`` load
6
+ with pydantic, PyYAML and osprey-connectors only, and the OSPREY framework
7
+ imports them. The search stack (xopt, torch, botorch, polars, redis, fastapi)
8
+ is the ``service`` extra, installed only in the optimizer's image.
9
+
10
+ Sub-packages:
11
+ - models: request/response models, environment protocols, enums
12
+ - environments: environment files and the connector-driven environment
13
+ - api: FastAPI app, routers, job tracking and control (needs ``service``)
14
+ - optimization: phase runner, evaluator, Xopt backend, custom generators
15
+ - services: execution and orchestration inside the job worker
16
+ """
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2026.11.0'
22
+ __version_tuple__ = version_tuple = (2026, 11, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,12 @@
1
+ """The optimizer's HTTP service. Needs the ``service`` extra."""
2
+
3
+ import importlib.util
4
+
5
+ _SERVICE_ROOTS = ("fastapi", "redis", "polars", "xopt", "torch")
6
+
7
+ _missing = [name for name in _SERVICE_ROOTS if importlib.util.find_spec(name) is None]
8
+ if _missing:
9
+ raise ImportError(
10
+ f"osprey_optimizer.api needs the optimizer's service extra (missing: {', '.join(_missing)}). "
11
+ 'Install it with: pip install "osprey-optimizer[service]"'
12
+ )
@@ -0,0 +1,58 @@
1
+ """FastAPI dependency callables for shared singletons.
2
+
3
+ Each function reads from ``request.app.state``, which is populated by the
4
+ lifespan handler in ``main.py``. Use them with ``Depends()`` in endpoint
5
+ signatures so that routers stay decoupled from initialization logic.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from fastapi import Depends, Query, Request
12
+
13
+ from osprey_optimizer.api.job_status import load_job_meta
14
+ from osprey_optimizer.api.job_tracker import JobMeta, JobTracker
15
+ from osprey_optimizer.models.config import ControlTarget, ExecutionMode, machine_config
16
+ from osprey_optimizer.services.plugin_registry import EnvironmentRegistry
17
+
18
+
19
+ def get_job_tracker(request: Request) -> JobTracker:
20
+ return request.app.state.job_tracker # type: ignore[no-any-return]
21
+
22
+
23
+ def require_job_meta(job_id: str, tracker: JobTracker = Depends(get_job_tracker)) -> JobMeta:
24
+ """Resolve the path job's metadata once (404 if unknown, 500 if corrupt).
25
+
26
+ Endpoints under ``/jobs/{job_id}`` inject this instead of re-checking
27
+ existence and re-reading the record: FastAPI caches it for the request, so
28
+ the type/creation-time checks they used to make one call each now share a
29
+ single Redis read.
30
+ """
31
+ return load_job_meta(tracker, job_id)
32
+
33
+
34
+ def get_environment_registry(request: Request) -> EnvironmentRegistry:
35
+ return request.app.state.environment_registry # type: ignore[no-any-return]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Machine:
40
+ """The machine a request reaches, as the request named it."""
41
+
42
+ target: ControlTarget
43
+ execution_mode: ExecutionMode | None = None
44
+
45
+ def env_config(self) -> dict[str, Any]:
46
+ return machine_config(self.target, self.execution_mode)
47
+
48
+
49
+ def require_machine(
50
+ target: ControlTarget = Query(
51
+ description="The machine this call reaches: va, standin or live. No default."
52
+ ),
53
+ execution_mode: ExecutionMode | None = Query(
54
+ default=None, description="readonly: reads only; every write is refused."
55
+ ),
56
+ ) -> Machine:
57
+ """Every endpoint that reaches a machine names it; there is no ambient target."""
58
+ return Machine(target=target, execution_mode=execution_mode)
@@ -0,0 +1,294 @@
1
+ """Shared helpers for loading environments and reading/writing variables.
2
+
3
+ Extracted from ``routers/environments.py`` so multiple routers (environments,
4
+ run-config, results/restore) share one machine-access path and error mapping.
5
+ """
6
+
7
+ import logging
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from fastapi import HTTPException, status
13
+
14
+ if TYPE_CHECKING:
15
+ from osprey_optimizer.api.job_tracker import JobTracker
16
+
17
+ from osprey_optimizer.models.api import EnvironmentDetails, EnvironmentStatusResponse
18
+ from osprey_optimizer.models.config import (
19
+ ControlTarget,
20
+ ExecutionMode,
21
+ Preset,
22
+ machine_config,
23
+ )
24
+ from osprey_optimizer.models.exceptions import ConnectionError as OptimizerConnectionError
25
+ from osprey_optimizer.models.exceptions import TimeoutError as OptimizerTimeoutError
26
+ from osprey_optimizer.models.exceptions import VariableOutOfBoundsError
27
+ from osprey_optimizer.models.protocols import EnvironmentProtocol
28
+ from osprey_optimizer.services.plugin_registry import EnvironmentRegistry
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def require_known_environment(name: str, registry: EnvironmentRegistry) -> None:
34
+ """422 unless ``name`` is a discoverable environment."""
35
+ known = {e.name for e in registry.list_environments()}
36
+ if name not in known:
37
+ raise HTTPException(
38
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
39
+ detail=f"Unknown environment '{name}'.",
40
+ )
41
+
42
+
43
+ def environment_details(name: str, registry: EnvironmentRegistry) -> EnvironmentDetails:
44
+ """Assemble an environment's metadata — the single details builder.
45
+
46
+ Shared by the environments router and the current-environment reads so the
47
+ field set can't drift between endpoints.
48
+ """
49
+ env = load_environment(name, registry)
50
+ with env:
51
+ return EnvironmentDetails.from_environment(name, env)
52
+
53
+
54
+ def probe_environment_health(
55
+ name: str, registry: EnvironmentRegistry, target: ControlTarget
56
+ ) -> EnvironmentStatusResponse:
57
+ """Probe an environment's connectivity on one target — the single health probe.
58
+
59
+ 404 for an unknown name; any other probe failure degrades to
60
+ ``connected=None`` with the error message rather than a 5xx.
61
+ """
62
+ try:
63
+ env = lookup_environment(name, registry, machine_config(target))
64
+ with env:
65
+ connected = env.check_connection()
66
+ return EnvironmentStatusResponse(name=name, connected=connected, message=None)
67
+ except HTTPException:
68
+ raise
69
+ except Exception as e:
70
+ logger.warning("Connectivity check failed for '%s': %s", name, e)
71
+ return EnvironmentStatusResponse(name=name, connected=None, message=str(e))
72
+
73
+
74
+ def is_sim_target(target: ControlTarget) -> bool:
75
+ """True for the virtual accelerator, which no real-hardware safety gate applies to.
76
+
77
+ It gates restore-token safety and the health probe. The stand-in is not a
78
+ simulator here: it is where an operator rehearses real-machine behaviour.
79
+ """
80
+ return target == "va"
81
+
82
+
83
+ def _environment_connected(name: str, registry: EnvironmentRegistry, target: ControlTarget) -> bool:
84
+ return probe_environment_health(name, registry, target).connected is True
85
+
86
+
87
+ def _assert_healthy(
88
+ name: str, registry: EnvironmentRegistry, target: ControlTarget, override_unhealthy: bool
89
+ ) -> None:
90
+ if is_sim_target(target) or override_unhealthy:
91
+ return
92
+ if not _environment_connected(name, registry, target):
93
+ raise HTTPException(
94
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
95
+ detail=f"Environment '{name}' is not reachable on '{target}'; pass override_unhealthy to proceed.",
96
+ )
97
+
98
+
99
+ def guard_environment_write(
100
+ name: str,
101
+ target: ControlTarget,
102
+ tracker: "JobTracker",
103
+ registry: EnvironmentRegistry,
104
+ *,
105
+ override_unhealthy: bool,
106
+ ) -> None:
107
+ """Point-in-time precondition for a manual hardware write.
108
+
109
+ Requires no active job, and the environment healthy on ``target`` (or the
110
+ override). NOT a lock held across the write — a job may claim the slot after
111
+ this returns; that residual race is the accepted best-effort behavior. Whether
112
+ ``target`` may be written at all is the connector's decision, on every put.
113
+ """
114
+ if tracker.get_active_job() is not None:
115
+ raise HTTPException(
116
+ status_code=status.HTTP_409_CONFLICT,
117
+ detail="A job is active; manual writes are locked.",
118
+ )
119
+ _assert_healthy(name, registry, target, override_unhealthy)
120
+
121
+
122
+ def guard_environment_start(
123
+ name: str, target: ControlTarget, registry: EnvironmentRegistry, *, override_unhealthy: bool
124
+ ) -> None:
125
+ """Point-in-time precondition for a job start: the environment healthy on its target.
126
+
127
+ The active-slot claim (claim_active_job) enforces the single-running-job
128
+ rule, so this does not check the active-job lock.
129
+ """
130
+ _assert_healthy(name, registry, target, override_unhealthy)
131
+
132
+
133
+ def machine_env_config(
134
+ target: ControlTarget, execution_mode: ExecutionMode | None
135
+ ) -> dict[str, Any]:
136
+ """Environment config for a call that reaches ``target``."""
137
+ return machine_config(target, execution_mode)
138
+
139
+
140
+ def lookup_environment(
141
+ name: str, registry: EnvironmentRegistry, env_config: dict[str, Any] | None = None
142
+ ) -> EnvironmentProtocol:
143
+ """Resolve an environment by name, mapping only the unknown-name case to 404.
144
+
145
+ Use this when the caller wants to handle other load failures itself (e.g.
146
+ ``/status`` degrades them to ``connected=False`` rather than a 500).
147
+ """
148
+ try:
149
+ return registry.load_environment(name, env_config or {})
150
+ except KeyError as e:
151
+ raise HTTPException(
152
+ status_code=status.HTTP_404_NOT_FOUND,
153
+ detail=f"Environment '{name}' not found",
154
+ ) from e
155
+
156
+
157
+ def load_environment(
158
+ name: str, registry: EnvironmentRegistry, env_config: dict[str, Any] | None = None
159
+ ) -> EnvironmentProtocol:
160
+ """Load an environment by name, mapping failures to HTTP errors."""
161
+ try:
162
+ return lookup_environment(name, registry, env_config)
163
+ except HTTPException:
164
+ raise
165
+ except Exception as e:
166
+ logger.exception("Failed to load environment '%s'", name)
167
+ raise HTTPException(
168
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
169
+ detail=f"Failed to load environment '{name}': {e}",
170
+ ) from e
171
+
172
+
173
+ @contextmanager
174
+ def read_errors(name: str, action: str) -> Iterator[None]:
175
+ """Map control-system read failures to HTTP errors.
176
+
177
+ ``action`` names the read (e.g. ``"get variables"``) and is interpolated
178
+ into the 500 detail. The 503 path is the catastrophic case where the CA
179
+ worker thread never answered (wedged or never started).
180
+
181
+ Wrap only the control-system call; keep name validation before the block
182
+ and value transforms after it so their errors are not misclassified.
183
+ """
184
+ try:
185
+ yield
186
+ except OptimizerTimeoutError as e:
187
+ logger.exception("CA worker unresponsive while reading from '%s'", name)
188
+ raise HTTPException(
189
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
190
+ detail=f"Control system worker unresponsive: {e}",
191
+ ) from e
192
+ except Exception as e:
193
+ logger.exception("Failed to %s from '%s'", action, name)
194
+ raise HTTPException(
195
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
196
+ detail=f"Failed to {action}: {e}",
197
+ ) from e
198
+
199
+
200
+ @contextmanager
201
+ def write_errors(name: str, action: str) -> Iterator[None]:
202
+ """Map control-system write failures to HTTP errors.
203
+
204
+ ``action`` names the write (e.g. ``"set variables"``) and is interpolated
205
+ into the 500 detail. An out-of-bounds/unknown/non-finite value is a client
206
+ error (422); an unreachable or unresponsive control system is a 503.
207
+
208
+ Wrap only the control-system call; keep name validation before the block
209
+ and value transforms after it so their errors are not misclassified.
210
+ """
211
+ try:
212
+ yield
213
+ except VariableOutOfBoundsError as e:
214
+ # Unknown variable, out-of-bounds, or non-finite value: client error.
215
+ raise HTTPException(
216
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
217
+ detail=str(e),
218
+ ) from e
219
+ except (OptimizerConnectionError, OptimizerTimeoutError) as e:
220
+ logger.exception("Control system unreachable while trying to %s on '%s'", action, name)
221
+ raise HTTPException(
222
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
223
+ detail=f"Control system unavailable: {e}",
224
+ ) from e
225
+ except Exception as e:
226
+ logger.exception("Failed to %s on '%s'", action, name)
227
+ raise HTTPException(
228
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
229
+ detail=f"Failed to {action}: {e}",
230
+ ) from e
231
+
232
+
233
+ def validate_observable_names(names: list[str], env: EnvironmentProtocol) -> None:
234
+ """Raise 422 if any observable name is not in the environment metadata."""
235
+ unknown = [n for n in names if n not in env.observables]
236
+ if unknown:
237
+ raise HTTPException(
238
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
239
+ detail=f"Unknown observable(s): {unknown}",
240
+ )
241
+
242
+
243
+ def lookup_preset(registry: EnvironmentRegistry, name: str, preset: str) -> Preset:
244
+ """Environment ``name``'s preset ``preset``; 404 for either unknown, 422 for a bad declaration."""
245
+ presets = environment_presets(registry, name)
246
+ if preset not in presets:
247
+ raise HTTPException(
248
+ status_code=status.HTTP_404_NOT_FOUND,
249
+ detail=f"Environment '{name}' has no preset '{preset}'. Available: {sorted(presets)}",
250
+ )
251
+ return presets[preset]
252
+
253
+
254
+ def environment_presets(registry: EnvironmentRegistry, name: str) -> dict[str, Preset]:
255
+ """Environment ``name``'s presets; 404 when it is unknown, 422 for a bad declaration."""
256
+ try:
257
+ return registry.get_presets(name)
258
+ except KeyError as e:
259
+ raise HTTPException(
260
+ status_code=status.HTTP_404_NOT_FOUND,
261
+ detail=f"Environment '{name}' not found",
262
+ ) from e
263
+ except ValueError as e:
264
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) from e
265
+
266
+
267
+ def validate_variable_names(
268
+ names: list[str],
269
+ env: EnvironmentProtocol,
270
+ field: str = "variable",
271
+ ) -> None:
272
+ """Raise 422 if any name is not in the environment metadata.
273
+
274
+ ``field`` labels the offending input so callers passing different fields
275
+ (e.g. ``overrides`` vs ``current_values``) get distinguishable messages.
276
+ """
277
+ unknown = [n for n in names if n not in env.variables]
278
+ if unknown:
279
+ raise HTTPException(
280
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
281
+ detail=f"Unknown {field}(s): {unknown}",
282
+ )
283
+
284
+
285
+ def apply_variables_to_environment(
286
+ env: EnvironmentProtocol, name: str, variables: dict[str, float]
287
+ ) -> None:
288
+ """Write variables to the machine, mapping control errors to HTTP errors.
289
+
290
+ Owns the environment context: pass a freshly-loaded (not already-open)
291
+ ``env`` from :func:`load_environment`; this opens and closes it.
292
+ """
293
+ with write_errors(name, "set variables"), env:
294
+ env.set_variables(variables)