mlflow-optimization 0.1.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 (40) hide show
  1. mlflow_optimization/__init__.py +50 -0
  2. mlflow_optimization/_bootstrap.py +129 -0
  3. mlflow_optimization/common/diagnostics.py +19 -0
  4. mlflow_optimization/common/patching.py +82 -0
  5. mlflow_optimization/features/annotations/annotate.py +97 -0
  6. mlflow_optimization/features/annotations/types.py +22 -0
  7. mlflow_optimization/features/artifacts/policy.py +16 -0
  8. mlflow_optimization/features/artifacts/ports.py +17 -0
  9. mlflow_optimization/features/autolog/ports.py +49 -0
  10. mlflow_optimization/features/autolog/service.py +147 -0
  11. mlflow_optimization/features/autolog/types.py +31 -0
  12. mlflow_optimization/features/metadata/collect.py +52 -0
  13. mlflow_optimization/features/metadata/types.py +42 -0
  14. mlflow_optimization/features/metrics/normalize.py +121 -0
  15. mlflow_optimization/features/metrics/types.py +54 -0
  16. mlflow_optimization/features/outcomes/classify.py +53 -0
  17. mlflow_optimization/features/outcomes/types.py +40 -0
  18. mlflow_optimization/features/runs/ports.py +30 -0
  19. mlflow_optimization/features/runs/record.py +231 -0
  20. mlflow_optimization/features/runs/types.py +43 -0
  21. mlflow_optimization/integrations/cp_sat/__init__.py +5 -0
  22. mlflow_optimization/integrations/cp_sat/adapter.py +464 -0
  23. mlflow_optimization/integrations/gurobipy/__init__.py +5 -0
  24. mlflow_optimization/integrations/gurobipy/adapter.py +474 -0
  25. mlflow_optimization/integrations/mathopt/__init__.py +1 -0
  26. mlflow_optimization/integrations/mathopt/adapter.py +939 -0
  27. mlflow_optimization/integrations/mlflow/artifacts.py +44 -0
  28. mlflow_optimization/integrations/mlflow/runs.py +480 -0
  29. mlflow_optimization/integrations/pyomo/__init__.py +5 -0
  30. mlflow_optimization/integrations/pyomo/adapter.py +1499 -0
  31. mlflow_optimization/integrations/pyscipopt/__init__.py +5 -0
  32. mlflow_optimization/integrations/pyscipopt/adapter.py +657 -0
  33. mlflow_optimization/integrations/routing/__init__.py +5 -0
  34. mlflow_optimization/integrations/routing/adapter.py +429 -0
  35. mlflow_optimization/py.typed +0 -0
  36. mlflow_optimization-0.1.0.dist-info/METADATA +52 -0
  37. mlflow_optimization-0.1.0.dist-info/RECORD +40 -0
  38. mlflow_optimization-0.1.0.dist-info/WHEEL +4 -0
  39. mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-APACHE +176 -0
  40. mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
@@ -0,0 +1,50 @@
1
+ """Automatic MLflow logging of mathematical optimization solves."""
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from mlflow_optimization import _bootstrap
6
+ from mlflow_optimization.common.diagnostics import MlflowOptimizationWarning
7
+ from mlflow_optimization.features.annotations.annotate import context
8
+ from mlflow_optimization.features.runs.record import SolveRun, last_solve
9
+
10
+ __all__ = ["MlflowOptimizationWarning", "SolveRun", "autolog", "context", "last_solve"]
11
+
12
+
13
+ def autolog(
14
+ disable: bool = False,
15
+ *,
16
+ libraries: Iterable[str] | None = None,
17
+ log_solver_parameters: bool = True,
18
+ log_solver_metrics: bool = True,
19
+ log_artifacts: bool = True,
20
+ ) -> None:
21
+ """Configure automatic logging of optimization solves.
22
+
23
+ Parameters
24
+ ----------
25
+ disable : bool, default False
26
+ Disable automatic logging; other settings are ignored when True.
27
+ libraries : Iterable[str] or None, optional
28
+ Supported names: gurobipy, pyscipopt, pyomo, mathopt, cp_sat, routing.
29
+ None selects all available integrations; an empty iterable selects none.
30
+ log_solver_parameters : bool, default True
31
+ Whether solver parameters are logged.
32
+ log_solver_metrics : bool, default True
33
+ Whether native solver metrics are logged.
34
+ log_artifacts : bool, default True
35
+ Whether files attached through ``SolveRun.log_artifact`` are logged.
36
+
37
+ Raises
38
+ ------
39
+ ImportError
40
+ An explicitly selected integration cannot be imported.
41
+ ValueError
42
+ An unsupported library name is selected while logging is enabled.
43
+ """
44
+ _bootstrap.configure(
45
+ disable=disable,
46
+ libraries=libraries,
47
+ log_solver_parameters=log_solver_parameters,
48
+ log_solver_metrics=log_solver_metrics,
49
+ log_artifacts=log_artifacts,
50
+ )
@@ -0,0 +1,129 @@
1
+ """Composition root: wires feature ports to concrete adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from importlib import import_module
7
+ from types import ModuleType
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from mlflow_optimization.features.autolog.ports import SolverIntegration
12
+ from mlflow_optimization.features.autolog.service import AutologManager
13
+ from mlflow_optimization.features.runs.record import RecordSolve
14
+ from mlflow_optimization.features.runs.types import RecordOptions
15
+ from mlflow_optimization.integrations.mlflow.artifacts import MlflowArtifactStore
16
+ from mlflow_optimization.integrations.mlflow.runs import MlflowRunStore
17
+
18
+ INTEGRATION_MODULES: dict[str, tuple[str, str]] = {
19
+ "gurobipy": ("mlflow_optimization.integrations.gurobipy.adapter", "GurobiAdapter"),
20
+ "pyscipopt": (
21
+ "mlflow_optimization.integrations.pyscipopt.adapter",
22
+ "PyScipOptAdapter",
23
+ ),
24
+ "pyomo": ("mlflow_optimization.integrations.pyomo.adapter", "PyomoAdapter"),
25
+ "mathopt": ("mlflow_optimization.integrations.mathopt.adapter", "MathOptAdapter"),
26
+ "cp_sat": ("mlflow_optimization.integrations.cp_sat.adapter", "CpSatAdapter"),
27
+ "routing": ("mlflow_optimization.integrations.routing.adapter", "RoutingAdapter"),
28
+ }
29
+
30
+ _record_solve: RecordSolve | None = None
31
+ _autolog_manager: AutologManager | None = None
32
+ _integration_cache: dict[type[SolverIntegration], SolverIntegration] = {}
33
+
34
+
35
+ def configure(
36
+ *,
37
+ disable: bool,
38
+ libraries: Iterable[str] | None,
39
+ log_solver_parameters: bool,
40
+ log_solver_metrics: bool,
41
+ log_artifacts: bool,
42
+ ) -> None:
43
+ """Apply an autolog configuration to the process-wide wiring."""
44
+ global _autolog_manager
45
+ global _record_solve
46
+
47
+ integrations: list[SolverIntegration] = []
48
+ if not disable:
49
+ integrations = _resolve_integrations(libraries)
50
+
51
+ if _autolog_manager is None:
52
+ from mlflow_optimization.features.autolog.service import AutologManager
53
+ from mlflow_optimization.features.runs.record import RecordSolve
54
+ from mlflow_optimization.features.runs.types import RecordOptions
55
+ from mlflow_optimization.integrations.mlflow.artifacts import (
56
+ MlflowArtifactStore,
57
+ )
58
+ from mlflow_optimization.integrations.mlflow.runs import MlflowRunStore
59
+
60
+ run_store: MlflowRunStore = MlflowRunStore()
61
+ artifact_store: MlflowArtifactStore = MlflowArtifactStore()
62
+ record_options: RecordOptions = RecordOptions()
63
+ if not disable:
64
+ record_options = RecordOptions(
65
+ log_solver_parameters=log_solver_parameters,
66
+ log_solver_metrics=log_solver_metrics,
67
+ log_artifacts=log_artifacts,
68
+ )
69
+ record_solve: RecordSolve = RecordSolve(
70
+ run_store, artifact_store, record_options
71
+ )
72
+ autolog_manager: AutologManager = AutologManager(record_solve)
73
+
74
+ _record_solve = record_solve
75
+ _autolog_manager = autolog_manager
76
+ else:
77
+ assert _record_solve is not None
78
+ if not disable:
79
+ _record_solve.options.log_solver_parameters = log_solver_parameters
80
+ _record_solve.options.log_solver_metrics = log_solver_metrics
81
+ _record_solve.options.log_artifacts = log_artifacts
82
+
83
+ assert _autolog_manager is not None
84
+ if disable:
85
+ _autolog_manager.disable()
86
+ return
87
+
88
+ _autolog_manager.enable(integrations)
89
+
90
+
91
+ def _resolve_integrations(libraries: Iterable[str] | None) -> list[SolverIntegration]:
92
+ """The selected solver integrations."""
93
+ library_modules: dict[str, tuple[str, str]] = dict(INTEGRATION_MODULES)
94
+ selected_libraries: tuple[str, ...] = (
95
+ tuple(library_modules) if libraries is None else tuple(dict.fromkeys(libraries))
96
+ )
97
+
98
+ for library_name in selected_libraries:
99
+ if library_name not in library_modules:
100
+ valid_library_names: str = ", ".join(library_modules)
101
+ raise ValueError(
102
+ f"Unknown library {library_name!r}. Valid names: {valid_library_names}"
103
+ )
104
+
105
+ integrations: list[SolverIntegration] = []
106
+ for library_name in selected_libraries:
107
+ module_name: str
108
+ class_name: str
109
+ module_name, class_name = library_modules[library_name]
110
+ try:
111
+ integration_module: ModuleType = import_module(module_name)
112
+ integration_class: type[SolverIntegration] = getattr(
113
+ integration_module, class_name
114
+ )
115
+ cached_integration: SolverIntegration | None = _integration_cache.get(
116
+ integration_class
117
+ )
118
+ if cached_integration is not None:
119
+ integration: SolverIntegration = cached_integration
120
+ else:
121
+ integration = integration_class()
122
+ _integration_cache[integration_class] = integration
123
+ except ImportError:
124
+ if libraries is not None:
125
+ raise
126
+ continue
127
+ integrations.append(integration)
128
+
129
+ return integrations
@@ -0,0 +1,19 @@
1
+ """Notification channel shared by every feature and integration."""
2
+
3
+ import logging
4
+ import warnings
5
+
6
+
7
+ class MlflowOptimizationWarning(UserWarning):
8
+ """The category of every warning emitted by this library."""
9
+
10
+
11
+ def warn(message: str, *, stacklevel: int = 2) -> None:
12
+ """Emit an ``MlflowOptimizationWarning``."""
13
+ try:
14
+ warnings.warn(message, MlflowOptimizationWarning, stacklevel=stacklevel + 1)
15
+ except BaseException: # noqa: BLE001
16
+ try:
17
+ logging.getLogger("mlflow_optimization").warning(message)
18
+ except BaseException: # noqa: BLE001,S110
19
+ pass
@@ -0,0 +1,82 @@
1
+ """Reversible attribute replacement."""
2
+
3
+ import sys
4
+ from collections.abc import Callable, Mapping
5
+ from inspect import signature
6
+ from types import ModuleType
7
+ from typing import Any
8
+
9
+
10
+ def receiver_of(
11
+ original: Callable[..., Any], args: tuple[Any, ...], kwargs: Mapping[str, Any]
12
+ ) -> Any:
13
+ """The receiver of a method call."""
14
+ receiver_name: str | None = next(iter(signature(original).parameters), None)
15
+ if receiver_name is None:
16
+ return None
17
+ if args:
18
+ return args[0]
19
+ return kwargs.get(receiver_name)
20
+
21
+
22
+ def rebind_module_names(
23
+ original: Any,
24
+ replacement: Any,
25
+ ) -> list[tuple[ModuleType, str]]:
26
+ """The module-level names rebound from an original object to a replacement."""
27
+ rebound_names: list[tuple[ModuleType, str]] = []
28
+ modules: tuple[Any, ...] = tuple(sys.modules.values())
29
+ for module_value in modules:
30
+ if not isinstance(module_value, ModuleType):
31
+ continue
32
+ module: ModuleType = module_value
33
+ module_values: dict[str, Any] = vars(module)
34
+ module_bindings: tuple[tuple[str, Any], ...] = tuple(module_values.items())
35
+ for name, value in module_bindings:
36
+ if value is not original:
37
+ continue
38
+ module_values[name] = replacement
39
+ rebound_names.append((module, name))
40
+ return rebound_names
41
+
42
+
43
+ def restore_module_names(
44
+ rebound_names: list[tuple[ModuleType, str]],
45
+ original: Any,
46
+ replacement: Any,
47
+ ) -> None:
48
+ """A conditional restoration of module-level names."""
49
+ for module, name in reversed(rebound_names):
50
+ module_values: dict[str, Any] = vars(module)
51
+ if name not in module_values or module_values[name] is not replacement:
52
+ continue
53
+ module_values[name] = original
54
+
55
+
56
+ class Patch:
57
+ """A reversible replacement of one attribute on a class or module."""
58
+
59
+ def __init__(self, owner: Any, name: str) -> None:
60
+ self.owner: Any = owner
61
+ self.name: str = name
62
+ self._installed: bool = False
63
+
64
+ def install(self, factory: Callable[[Any], Any]) -> None:
65
+ """Replace the attribute with ``factory(original)``; no-op when installed."""
66
+ if self._installed:
67
+ return
68
+ original: Any = getattr(self.owner, self.name)
69
+ self._own: bool = self.name in vars(self.owner)
70
+ self._descriptor: Any = vars(self.owner).get(self.name)
71
+ setattr(self.owner, self.name, factory(original))
72
+ self._installed = True
73
+
74
+ def uninstall(self) -> None:
75
+ """Restore the exact original attribute; no-op when not installed."""
76
+ if not self._installed:
77
+ return
78
+ if self._own:
79
+ setattr(self.owner, self.name, self._descriptor)
80
+ else:
81
+ delattr(self.owner, self.name)
82
+ self._installed = False
@@ -0,0 +1,97 @@
1
+ """Scoping and naming rules for user annotations."""
2
+
3
+ from collections.abc import Iterator, Mapping
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar, Token
6
+ from math import isfinite
7
+
8
+ from mlflow_optimization.common.diagnostics import warn
9
+
10
+ from .types import Annotation, UserMetric
11
+
12
+ _annotation_context: ContextVar[Annotation] = ContextVar(
13
+ "annotation_context",
14
+ default=Annotation(), # noqa: B039 -- context() copies tags before merging.
15
+ )
16
+
17
+
18
+ @contextmanager
19
+ def context(
20
+ *,
21
+ instance: str | None = None,
22
+ description: str | None = None,
23
+ tags: Mapping[str, str] | None = None,
24
+ ) -> Iterator[None]:
25
+ """A scope whose annotations apply to every solve started inside it.
26
+
27
+ Parameters
28
+ ----------
29
+ instance : str, optional
30
+ Instance name; inherited from the enclosing scope when omitted.
31
+ description : str, optional
32
+ Description; inherited from the enclosing scope when omitted.
33
+ tags : Mapping[str, str], optional
34
+ User tags merge with enclosing tags; inner values replace outer values for
35
+ matching keys. Keys prefixed ``optimization.`` or ``mlflow.`` are ignored.
36
+ """
37
+ enclosing_annotation: Annotation = current_annotation()
38
+ merged_tags: dict[str, str] = dict(enclosing_annotation.tags)
39
+ if tags is not None:
40
+ merged_tags.update(user_tags(tags))
41
+ merged_annotation: Annotation = Annotation(
42
+ instance=instance if instance is not None else enclosing_annotation.instance,
43
+ description=(
44
+ description if description is not None else enclosing_annotation.description
45
+ ),
46
+ tags=merged_tags,
47
+ )
48
+ token: Token[Annotation] = _annotation_context.set(merged_annotation)
49
+ try:
50
+ yield
51
+ finally:
52
+ _annotation_context.reset(token)
53
+
54
+
55
+ def current_annotation() -> Annotation:
56
+ """The merged annotations of the enclosing ``context`` scopes."""
57
+ return _annotation_context.get()
58
+
59
+
60
+ def annotation_tags(annotation: Annotation) -> dict[str, str]:
61
+ """The tags that record an annotation."""
62
+ tags: dict[str, str] = user_tags(annotation.tags)
63
+ if annotation.instance is not None:
64
+ tags["optimization.instance"] = annotation.instance
65
+ if annotation.description is not None:
66
+ tags["mlflow.note.content"] = annotation.description
67
+ return tags
68
+
69
+
70
+ def user_tags(tags: Mapping[str, str]) -> dict[str, str]:
71
+ """The subset of user tags that may be recorded."""
72
+ accepted_tags: dict[str, str] = {}
73
+ for key, value in tags.items():
74
+ if key.startswith(("optimization.", "mlflow.")):
75
+ warn(f"Ignoring user tag with reserved key {key!r}.")
76
+ else:
77
+ accepted_tags[key] = value
78
+ return accepted_tags
79
+
80
+
81
+ def user_metric(
82
+ name: str, value: float, *, unit: str | None = None, description: str | None = None
83
+ ) -> UserMetric | None:
84
+ """The record of one user metric, or None when it may not be recorded."""
85
+ if name.startswith(("optimization.", "mlflow.")):
86
+ warn(f"Ignoring user metric with reserved name {name!r}.")
87
+ return None
88
+ if not isfinite(value):
89
+ warn(f"Ignoring user metric {name!r} because its value is not finite.")
90
+ return None
91
+ metric_key: str = f"user.{name}"
92
+ tags: dict[str, str] = {}
93
+ if unit is not None:
94
+ tags[f"{metric_key}.unit"] = unit
95
+ if description is not None:
96
+ tags[f"{metric_key}.description"] = description
97
+ return UserMetric(metric_key=metric_key, value=value, tags=tags)
@@ -0,0 +1,22 @@
1
+ """User-provided information attached to solves."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Annotation:
9
+ """The user annotations in effect for a solve."""
10
+
11
+ instance: str | None = None
12
+ description: str | None = None
13
+ tags: Mapping[str, str] = field(default_factory=dict)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class UserMetric:
18
+ """One user metric under final key names."""
19
+
20
+ metric_key: str
21
+ value: float
22
+ tags: Mapping[str, str]
@@ -0,0 +1,16 @@
1
+ """Rules for which explicit artifact requests are honored."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def artifact_refusal(local_path: str, *, enabled: bool) -> str | None:
7
+ """The reason an explicit artifact request is refused, or None when it is honored."""
8
+ if not enabled:
9
+ return "Artifact logging is disabled."
10
+ try:
11
+ is_file: bool = Path(local_path).is_file()
12
+ except OSError:
13
+ return f"Artifact path cannot be inspected: {local_path}"
14
+ if not is_file:
15
+ return f"Artifact path is not an existing file: {local_path}"
16
+ return None
@@ -0,0 +1,17 @@
1
+ """Contract of the artifact storage."""
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class ArtifactStoreError(Exception):
7
+ """An error raised when artifact storage fails."""
8
+
9
+
10
+ class ArtifactStore(Protocol):
11
+ """The storage of files attached to runs."""
12
+
13
+ def log_artifact(
14
+ self, run_id: str, local_path: str, artifact_path: str | None
15
+ ) -> None:
16
+ """Attach a local file to a run."""
17
+ ...
@@ -0,0 +1,49 @@
1
+ """Contracts between solve observation and its surroundings."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Protocol
5
+
6
+ from .types import CompletedSolve, SolveObservation
7
+
8
+
9
+ class ObservationError(Exception):
10
+ """An error raised when a solve observation cannot be built."""
11
+
12
+
13
+ class SolveObserver(Protocol):
14
+ """The entry through which an adapter routes a solve call."""
15
+
16
+ def observe[T](
17
+ self,
18
+ api: str,
19
+ call: Callable[[], T],
20
+ extract: Callable[[T | None, BaseException | None], SolveObservation],
21
+ ) -> T:
22
+ """The result of ``call()``, observed as one solve unless nested or disabled."""
23
+ ...
24
+
25
+
26
+ class SolverIntegration(Protocol):
27
+ """The hook of one Python solver API."""
28
+
29
+ api: str
30
+
31
+ def install(self, observer: SolveObserver) -> None:
32
+ """Route the SDK's solve entry points through ``observer``."""
33
+ ...
34
+
35
+ def uninstall(self) -> None:
36
+ """Restore the SDK's original solve entry points."""
37
+ ...
38
+
39
+
40
+ class SolveSink(Protocol):
41
+ """The destination of completed solves."""
42
+
43
+ def begin(self) -> object:
44
+ """The recording context captured at solve start."""
45
+ ...
46
+
47
+ def record(self, solve: CompletedSolve, context: object) -> None:
48
+ """Store one completed solve."""
49
+ ...
@@ -0,0 +1,147 @@
1
+ """Activation of integrations and arbitration of observed solve calls."""
2
+
3
+ from collections.abc import Callable, Iterator, Sequence
4
+ from contextvars import ContextVar, Token
5
+ from dataclasses import replace
6
+ from itertools import count
7
+ from threading import Lock
8
+ from time import perf_counter, time
9
+ from typing import cast
10
+ from uuid import uuid4
11
+
12
+ from mlflow_optimization.common.diagnostics import warn
13
+
14
+ from .ports import SolverIntegration, SolveSink
15
+ from .types import CompletedSolve, SolveObservation
16
+
17
+ _solve_indices: Iterator[int] = count(1)
18
+ _solve_index_lock: Lock = Lock()
19
+
20
+
21
+ class _SolveScope:
22
+ """The lifetime of an observed solve call."""
23
+
24
+ def __init__(self) -> None:
25
+ self.active: bool = True
26
+
27
+
28
+ _observing: ContextVar[_SolveScope | None] = ContextVar(
29
+ "autolog_observing", default=None
30
+ )
31
+
32
+
33
+ class AutologManager:
34
+ """The observer that turns outermost solve calls into completed solves."""
35
+
36
+ def __init__(self, sink: SolveSink) -> None:
37
+ self._sink: SolveSink = sink
38
+ self._integrations: list[SolverIntegration] = []
39
+ self._enabled: bool = False
40
+
41
+ def enable(self, integrations: Sequence[SolverIntegration]) -> None:
42
+ """Observe exactly the given integrations."""
43
+ for integration in self._integrations[:]:
44
+ if not any(integration is candidate for candidate in integrations):
45
+ integration.uninstall()
46
+ self._integrations.remove(integration)
47
+ for integration in integrations:
48
+ if not any(integration is installed for installed in self._integrations):
49
+ integration.install(self)
50
+ self._integrations.append(integration)
51
+ self._enabled = True
52
+
53
+ def disable(self) -> None:
54
+ """Stop observing and uninstall every integration."""
55
+ self._enabled = False
56
+ for integration in self._integrations[:]:
57
+ integration.uninstall()
58
+ self._integrations.remove(integration)
59
+
60
+ def observe[T](
61
+ self,
62
+ api: str,
63
+ call: Callable[[], T],
64
+ extract: Callable[[T | None, BaseException | None], SolveObservation],
65
+ ) -> T:
66
+ """The result of ``call()``, observed as one solve unless nested or disabled."""
67
+ parent: _SolveScope | None = _observing.get()
68
+ if not self._enabled or (parent is not None and parent.active):
69
+ return call()
70
+ scope: _SolveScope = _SolveScope()
71
+ token: Token[_SolveScope | None] = _observing.set(scope)
72
+ try:
73
+ failures: list[str] = []
74
+
75
+ def guard(step: str, operation: Callable[[], object]) -> object:
76
+ """The result of a guarded observation operation."""
77
+ try:
78
+ return operation()
79
+ # NOTE: A defect in logging must never change the result of the user's solve.
80
+ except Exception as exception: # noqa: BLE001
81
+ failures.append(
82
+ f"Solve {step} failed for {api}: "
83
+ f"{type(exception).__name__}: {exception}"
84
+ )
85
+ return None
86
+
87
+ context: object = guard("begin", self._sink.begin)
88
+ with _solve_index_lock:
89
+ solve_index: int = next(_solve_indices)
90
+ solve_id: str = uuid4().hex
91
+ start_time_ms: int = int(time() * 1000)
92
+ result: T | None = None
93
+
94
+ def finish(solve: CompletedSolve) -> None:
95
+ """Extract and record a completed solve."""
96
+ observation: SolveObservation | None = None
97
+ try:
98
+ observation = extract(result, solve.error)
99
+ # NOTE: A defect in extraction must never change the result of the user's solve.
100
+ except Exception as exception: # noqa: BLE001
101
+ failures.append(
102
+ f"Solve extraction failed for {api}: "
103
+ f"{type(exception).__name__}: {exception}"
104
+ )
105
+ self._sink.record(replace(solve, observation=observation), context)
106
+
107
+ start: float = perf_counter()
108
+ try:
109
+ result = call()
110
+ except BaseException as error:
111
+ end: float = perf_counter()
112
+ scope.active = False
113
+ end_time_ms: int = int(time() * 1000)
114
+ solve: CompletedSolve = CompletedSolve(
115
+ api,
116
+ solve_id,
117
+ solve_index,
118
+ start_time_ms,
119
+ end_time_ms,
120
+ end - start,
121
+ None,
122
+ error,
123
+ )
124
+ guard("recording", lambda: finish(solve))
125
+ for failure in failures:
126
+ warn(failure)
127
+ raise
128
+ end = perf_counter()
129
+ scope.active = False
130
+ end_time_ms = int(time() * 1000)
131
+ solve = CompletedSolve(
132
+ api,
133
+ solve_id,
134
+ solve_index,
135
+ start_time_ms,
136
+ end_time_ms,
137
+ end - start,
138
+ None,
139
+ None,
140
+ )
141
+ guard("recording", lambda: finish(solve))
142
+ for failure in failures:
143
+ warn(failure)
144
+ return cast(T, result)
145
+ finally:
146
+ scope.active = False
147
+ _observing.reset(token)
@@ -0,0 +1,31 @@
1
+ """What is observed about a solve."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from mlflow_optimization.features.metadata.types import ReportedMetadata
6
+ from mlflow_optimization.features.metrics.types import ReportedMetrics
7
+ from mlflow_optimization.features.outcomes.types import ReportedOutcome
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class SolveObservation:
12
+ """The SDK-free snapshot an adapter takes of one finished solve."""
13
+
14
+ api: str
15
+ outcome: ReportedOutcome
16
+ metrics: ReportedMetrics = field(default_factory=ReportedMetrics)
17
+ metadata: ReportedMetadata = field(default_factory=ReportedMetadata)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CompletedSolve:
22
+ """One finished outermost solve call as seen by the observer."""
23
+
24
+ api: str
25
+ solve_id: str
26
+ solve_index: int
27
+ start_time_ms: int
28
+ end_time_ms: int
29
+ wall_time_seconds: float
30
+ observation: SolveObservation | None
31
+ error: BaseException | None