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,52 @@
1
+ """Common rules for tracking the API, engine, problem and parameters."""
2
+
3
+ from .types import (
4
+ CollectedMetadata,
5
+ ParameterOrigin,
6
+ ReportedMetadata,
7
+ )
8
+
9
+
10
+ def _format_value(value: float | str | bool) -> str:
11
+ """A string representation of a recorded value."""
12
+ if isinstance(value, bool):
13
+ return str(value).lower()
14
+ return str(value)
15
+
16
+
17
+ def collect_metadata(
18
+ api: str, reported: ReportedMetadata | None, *, include_parameters: bool
19
+ ) -> CollectedMetadata:
20
+ """The recordable experiment conditions of a solve.
21
+
22
+ ``reported`` is None when extraction failed.
23
+ """
24
+ tags: dict[str, str] = {"optimization.api": api}
25
+ collected_parameters: dict[str, str] = {}
26
+ if reported is None:
27
+ tags["optimization.solver"] = "unknown"
28
+ return CollectedMetadata(tags=tags, params=collected_parameters)
29
+
30
+ tags["optimization.solver"] = reported.solver or "unknown"
31
+ if reported.api_version is not None:
32
+ tags["optimization.api_version"] = reported.api_version
33
+ if reported.solver_version is not None:
34
+ tags["optimization.solver_version"] = reported.solver_version
35
+ tags["optimization.wall_time_includes_problem_build"] = _format_value(
36
+ reported.wall_time_includes_problem_build
37
+ )
38
+
39
+ for problem_name, problem_value in reported.problem.items():
40
+ collected_parameters[f"problem.{problem_name}"] = _format_value(problem_value)
41
+
42
+ if include_parameters:
43
+ parameter_prefixes: dict[ParameterOrigin, str] = {
44
+ ParameterOrigin.EXPLICIT: "solver_param.",
45
+ ParameterOrigin.DEFAULT: "solver_param_default.",
46
+ ParameterOrigin.EFFECTIVE: "solver_param_effective.",
47
+ }
48
+ for parameter in reported.parameters:
49
+ parameter_key: str = parameter_prefixes[parameter.origin] + parameter.name
50
+ collected_parameters[parameter_key] = _format_value(parameter.value)
51
+
52
+ return CollectedMetadata(tags=tags, params=collected_parameters)
@@ -0,0 +1,42 @@
1
+ """Experiment conditions of a solve."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+
7
+
8
+ class ParameterOrigin(StrEnum):
9
+ """Where a solver parameter value comes from."""
10
+
11
+ EXPLICIT = "explicit"
12
+ DEFAULT = "default"
13
+ EFFECTIVE = "effective"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SolverParameter:
18
+ """One solver parameter value and its origin."""
19
+
20
+ name: str
21
+ value: int | float | str | bool
22
+ origin: ParameterOrigin = ParameterOrigin.EXPLICIT
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ReportedMetadata:
27
+ """The experiment conditions as an adapter read them from the solver."""
28
+
29
+ solver: str | None = None
30
+ api_version: str | None = None
31
+ solver_version: str | None = None
32
+ problem: Mapping[str, float | str | bool] = field(default_factory=dict)
33
+ parameters: Sequence[SolverParameter] = ()
34
+ wall_time_includes_problem_build: bool = False
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class CollectedMetadata:
39
+ """The tags and params recorded for the experiment conditions, under final key names."""
40
+
41
+ tags: Mapping[str, str]
42
+ params: Mapping[str, str]
@@ -0,0 +1,121 @@
1
+ """Common rules for metric names, missing values and gaps."""
2
+
3
+ from math import isfinite
4
+
5
+ from .types import (
6
+ COMMON_METRICS,
7
+ COMMON_STATISTICS,
8
+ MissingReason,
9
+ NormalizedMetrics,
10
+ ReportedMetrics,
11
+ )
12
+
13
+
14
+ def _record_reported_metric(
15
+ values: dict[str, float],
16
+ missing: dict[str, MissingReason],
17
+ metric_name: str,
18
+ reported_value: float | MissingReason,
19
+ ) -> None:
20
+ """Record a finite reported metric or its missing reason."""
21
+ if isinstance(reported_value, MissingReason):
22
+ missing[metric_name] = reported_value
23
+ return
24
+ if isfinite(reported_value):
25
+ values[metric_name] = reported_value
26
+ return
27
+ missing[metric_name] = MissingReason.UNDEFINED
28
+
29
+
30
+ def normalize_metrics(
31
+ reported: ReportedMetrics | None,
32
+ *,
33
+ api: str,
34
+ has_solution: bool | None,
35
+ wall_time_seconds: float,
36
+ include_native: bool,
37
+ ) -> NormalizedMetrics:
38
+ """The recordable metrics of a solve."""
39
+ values: dict[str, float] = {}
40
+ missing: dict[str, MissingReason] = {}
41
+ tags: dict[str, str] = {}
42
+
43
+ _record_reported_metric(values, missing, "wall_time_seconds", wall_time_seconds)
44
+
45
+ if reported is None:
46
+ tags["optimization.has_objective"] = "unknown"
47
+ for metric_name in COMMON_METRICS:
48
+ if metric_name != "wall_time_seconds":
49
+ missing[metric_name] = MissingReason.FAILED
50
+ else:
51
+ if reported.has_objective is None:
52
+ tags["optimization.has_objective"] = "unknown"
53
+ else:
54
+ tags["optimization.has_objective"] = str(reported.has_objective).lower()
55
+ if reported.sense is not None:
56
+ tags["optimization.objective_sense"] = reported.sense.value
57
+
58
+ if reported.has_objective is False:
59
+ missing["objective_value"] = MissingReason.UNDEFINED
60
+ missing["dual_bound"] = MissingReason.UNDEFINED
61
+ elif has_solution is False:
62
+ missing["objective_value"] = MissingReason.NO_SOLUTION
63
+ _record_reported_metric(values, missing, "dual_bound", reported.dual_bound)
64
+ else:
65
+ _record_reported_metric(
66
+ values, missing, "objective_value", reported.objective_value
67
+ )
68
+ _record_reported_metric(values, missing, "dual_bound", reported.dual_bound)
69
+
70
+ _record_reported_metric(
71
+ values, missing, "solver_time_seconds", reported.solver_time_seconds
72
+ )
73
+
74
+ for metric_name, reported_value in reported.statistics.items():
75
+ if metric_name in COMMON_STATISTICS:
76
+ _record_reported_metric(values, missing, metric_name, reported_value)
77
+
78
+ if reported.has_objective is False:
79
+ missing["absolute_gap"] = MissingReason.UNDEFINED
80
+ missing["relative_gap"] = MissingReason.UNDEFINED
81
+ elif has_solution is False:
82
+ missing["absolute_gap"] = MissingReason.NO_SOLUTION
83
+ missing["relative_gap"] = MissingReason.NO_SOLUTION
84
+ elif "objective_value" in missing:
85
+ missing["absolute_gap"] = missing["objective_value"]
86
+ missing["relative_gap"] = missing["objective_value"]
87
+ elif "dual_bound" in missing:
88
+ missing["absolute_gap"] = missing["dual_bound"]
89
+ missing["relative_gap"] = missing["dual_bound"]
90
+ else:
91
+ absolute_gap: float = abs(values["objective_value"] - values["dual_bound"])
92
+ if isfinite(absolute_gap):
93
+ values["absolute_gap"] = absolute_gap
94
+ if values["objective_value"] == 0.0:
95
+ if absolute_gap == 0.0:
96
+ values["relative_gap"] = 0.0
97
+ else:
98
+ missing["relative_gap"] = MissingReason.UNDEFINED
99
+ else:
100
+ relative_gap: float = absolute_gap / abs(values["objective_value"])
101
+ if isfinite(relative_gap):
102
+ values["relative_gap"] = relative_gap
103
+ else:
104
+ missing["relative_gap"] = MissingReason.UNDEFINED
105
+ else:
106
+ missing["absolute_gap"] = MissingReason.UNDEFINED
107
+ missing["relative_gap"] = MissingReason.UNDEFINED
108
+
109
+ for metric_name in COMMON_METRICS:
110
+ if metric_name not in values and metric_name not in missing:
111
+ missing[metric_name] = MissingReason.UNSUPPORTED
112
+ if metric_name in missing:
113
+ tags[f"optimization.missing.{metric_name}"] = missing[metric_name].value
114
+
115
+ if reported is not None and include_native:
116
+ for native_name, native_value in reported.native.items():
117
+ api_metric_name: str = f"{api}.{native_name}"
118
+ if isfinite(native_value):
119
+ values[api_metric_name] = native_value
120
+
121
+ return NormalizedMetrics(values=values, tags=tags)
@@ -0,0 +1,54 @@
1
+ """Objective, bound, time and search statistics of a solve."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+
7
+
8
+ class MissingReason(StrEnum):
9
+ """Why a value is absent."""
10
+
11
+ UNSUPPORTED = "unsupported"
12
+ UNDEFINED = "undefined"
13
+ NO_SOLUTION = "no_solution"
14
+ FAILED = "failed"
15
+
16
+
17
+ class ObjectiveSense(StrEnum):
18
+ """The optimization direction."""
19
+
20
+ MINIMIZE = "minimize"
21
+ MAXIMIZE = "maximize"
22
+
23
+
24
+ COMMON_STATISTICS = ("node_count", "simplex_iterations", "barrier_iterations")
25
+ COMMON_METRICS = (
26
+ "objective_value",
27
+ "dual_bound",
28
+ "absolute_gap",
29
+ "relative_gap",
30
+ "wall_time_seconds",
31
+ "solver_time_seconds",
32
+ *COMMON_STATISTICS,
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ReportedMetrics:
38
+ """The numbers of a solve as an adapter read them from the solver."""
39
+
40
+ has_objective: bool | None = None
41
+ sense: ObjectiveSense | None = None
42
+ objective_value: float | MissingReason = MissingReason.UNSUPPORTED
43
+ dual_bound: float | MissingReason = MissingReason.UNSUPPORTED
44
+ solver_time_seconds: float | MissingReason = MissingReason.UNSUPPORTED
45
+ statistics: Mapping[str, float | MissingReason] = field(default_factory=dict)
46
+ native: Mapping[str, float] = field(default_factory=dict)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class NormalizedMetrics:
51
+ """The metrics and metric-related tags recorded for a solve, under final key names."""
52
+
53
+ values: Mapping[str, float]
54
+ tags: Mapping[str, str]
@@ -0,0 +1,53 @@
1
+ """Common rules for termination and solution availability."""
2
+
3
+ from mlflow_optimization.common.diagnostics import warn
4
+
5
+ from .types import ReportedOutcome, SolveOutcome, Termination
6
+
7
+
8
+ def classify_outcome(
9
+ reported: ReportedOutcome | None,
10
+ error: BaseException | None,
11
+ *,
12
+ has_objective: bool | None = None,
13
+ ) -> SolveOutcome:
14
+ """The validated outcome of a solve."""
15
+ if reported is None:
16
+ if isinstance(error, KeyboardInterrupt):
17
+ return SolveOutcome("unknown", Termination.INTERRUPTED, None)
18
+ if error is not None:
19
+ return SolveOutcome("unknown", Termination.ERROR, None)
20
+ return SolveOutcome("unknown", Termination.UNKNOWN, None)
21
+
22
+ termination: Termination = reported.termination
23
+ if isinstance(error, KeyboardInterrupt):
24
+ termination = Termination.INTERRUPTED
25
+ elif error is not None:
26
+ termination = Termination.ERROR
27
+ else:
28
+ if termination is Termination.OPTIMAL and has_objective is False:
29
+ termination = Termination.FEASIBLE
30
+ if termination is Termination.FEASIBLE and reported.has_solution is False:
31
+ warn("Reported feasible termination conflicts with has_solution=False.")
32
+ termination = Termination.UNKNOWN
33
+ elif termination is Termination.OPTIMAL and reported.has_solution is False:
34
+ warn("Reported optimal termination conflicts with has_solution=False.")
35
+ elif termination is Termination.INFEASIBLE and reported.has_solution is True:
36
+ warn("Reported infeasible termination conflicts with has_solution=True.")
37
+
38
+ return SolveOutcome(reported.raw_status, termination, reported.has_solution)
39
+
40
+
41
+ def outcome_tags(outcome: SolveOutcome) -> dict[str, str]:
42
+ """The tags that describe an outcome."""
43
+ has_solution: str = "unknown"
44
+ if outcome.has_solution is True:
45
+ has_solution = "true"
46
+ elif outcome.has_solution is False:
47
+ has_solution = "false"
48
+
49
+ return {
50
+ "optimization.termination": outcome.termination.value,
51
+ "optimization.raw_status": outcome.raw_status,
52
+ "optimization.has_solution": has_solution,
53
+ }
@@ -0,0 +1,40 @@
1
+ """How a solve ended."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+
7
+ class Termination(StrEnum):
8
+ """The solver-independent reason a solve ended."""
9
+
10
+ OPTIMAL = "optimal"
11
+ FEASIBLE = "feasible"
12
+ INFEASIBLE = "infeasible"
13
+ UNBOUNDED = "unbounded"
14
+ INFEASIBLE_OR_UNBOUNDED = "infeasible_or_unbounded"
15
+ TIME_LIMIT = "time_limit"
16
+ ITERATION_LIMIT = "iteration_limit"
17
+ NODE_LIMIT = "node_limit"
18
+ SOLUTION_LIMIT = "solution_limit"
19
+ OTHER_LIMIT = "other_limit"
20
+ INTERRUPTED = "interrupted"
21
+ ERROR = "error"
22
+ UNKNOWN = "unknown"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ReportedOutcome:
27
+ """The end state of a solve as an adapter read it from the solver."""
28
+
29
+ raw_status: str
30
+ termination: Termination
31
+ has_solution: bool | None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class SolveOutcome:
36
+ """The validated end state of a solve."""
37
+
38
+ raw_status: str
39
+ termination: Termination
40
+ has_solution: bool | None
@@ -0,0 +1,30 @@
1
+ """Contract of the run storage."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Protocol
5
+
6
+ from .types import RunReference, SolveRecord
7
+
8
+
9
+ class RunStoreError(Exception):
10
+ """An error raised when run storage fails."""
11
+
12
+
13
+ class RunStore(Protocol):
14
+ """The storage of solve runs in an experiment tracker."""
15
+
16
+ def active_run(self) -> RunReference | None:
17
+ """The user's currently active run."""
18
+ ...
19
+
20
+ def save(self, record: SolveRecord, parent: RunReference | None) -> RunReference:
21
+ """Create, fill and terminate the run of one finished solve."""
22
+ ...
23
+
24
+ def log_metric(self, run: RunReference, key: str, value: float) -> None:
25
+ """Add one metric to an existing run."""
26
+ ...
27
+
28
+ def set_tags(self, run: RunReference, tags: Mapping[str, str]) -> None:
29
+ """Add tags to an existing run."""
30
+ ...
@@ -0,0 +1,231 @@
1
+ """Recording of completed solves and later additions to them."""
2
+
3
+ from collections.abc import Mapping
4
+ from contextvars import ContextVar
5
+ from dataclasses import dataclass
6
+
7
+ from mlflow_optimization.common.diagnostics import warn
8
+ from mlflow_optimization.features.annotations.annotate import (
9
+ annotation_tags,
10
+ current_annotation,
11
+ user_metric,
12
+ user_tags,
13
+ )
14
+ from mlflow_optimization.features.annotations.types import Annotation, UserMetric
15
+ from mlflow_optimization.features.artifacts.policy import artifact_refusal
16
+ from mlflow_optimization.features.artifacts.ports import (
17
+ ArtifactStore,
18
+ ArtifactStoreError,
19
+ )
20
+ from mlflow_optimization.features.autolog.types import CompletedSolve, SolveObservation
21
+ from mlflow_optimization.features.metadata.collect import collect_metadata
22
+ from mlflow_optimization.features.metadata.types import CollectedMetadata
23
+ from mlflow_optimization.features.metrics.normalize import normalize_metrics
24
+ from mlflow_optimization.features.metrics.types import NormalizedMetrics
25
+ from mlflow_optimization.features.outcomes.classify import (
26
+ classify_outcome,
27
+ outcome_tags,
28
+ )
29
+ from mlflow_optimization.features.outcomes.types import SolveOutcome
30
+
31
+ from .ports import RunStore, RunStoreError
32
+ from .types import RecordOptions, RunReference, RunStatus, SolveRecord
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _RecordingContext:
37
+ """The state captured at solve start."""
38
+
39
+ parent: RunReference | None
40
+ annotation: Annotation
41
+
42
+
43
+ class SolveRun:
44
+ """The handle of one recorded solve."""
45
+
46
+ def __init__(
47
+ self,
48
+ run: RunReference,
49
+ runs: RunStore,
50
+ artifacts: ArtifactStore,
51
+ options: RecordOptions,
52
+ ) -> None:
53
+ """Initialize a handle for a recorded solve.
54
+
55
+ Parameters
56
+ ----------
57
+ run : RunReference
58
+ Reference to the recorded solve's run.
59
+ runs : RunStore
60
+ Store used to add metrics and tags to the run.
61
+ artifacts : ArtifactStore
62
+ Store used to attach files to the run.
63
+ options : RecordOptions
64
+ Options controlling recorded solver data and artifact logging.
65
+ """
66
+ self.run_id: str = run.run_id
67
+ self._run: RunReference = run
68
+ self._runs: RunStore = runs
69
+ self._artifacts: ArtifactStore = artifacts
70
+ self._options: RecordOptions = options
71
+
72
+ def log_metric(
73
+ self,
74
+ name: str,
75
+ value: float,
76
+ *,
77
+ unit: str | None = None,
78
+ description: str | None = None,
79
+ ) -> None:
80
+ """Add a user metric to this solve.
81
+
82
+ Parameters
83
+ ----------
84
+ name : str
85
+ User metric name; its recorded key is prefixed with ``user.``.
86
+ value : float
87
+ Metric value.
88
+ unit : str, optional
89
+ Unit tag stored as ``user.{name}.unit``.
90
+ description : str, optional
91
+ Description tag stored as ``user.{name}.description``.
92
+ """
93
+ try:
94
+ entry: UserMetric | None = user_metric(
95
+ name, value, unit=unit, description=description
96
+ )
97
+ if entry is not None:
98
+ self._runs.log_metric(self._run, entry.metric_key, entry.value)
99
+ if entry.tags:
100
+ self._runs.set_tags(self._run, entry.tags)
101
+ except RunStoreError as error:
102
+ warn(f"Could not log metric: {error}")
103
+
104
+ def set_tags(self, tags: Mapping[str, str]) -> None:
105
+ """Add user tags to this solve.
106
+
107
+ Parameters
108
+ ----------
109
+ tags : Mapping[str, str]
110
+ Tags to add; keys beginning with ``optimization.`` or ``mlflow.``
111
+ are ignored.
112
+ """
113
+ try:
114
+ accepted: dict[str, str] = user_tags(tags)
115
+ if accepted:
116
+ self._runs.set_tags(self._run, accepted)
117
+ except RunStoreError as error:
118
+ warn(f"Could not set tags: {error}")
119
+
120
+ def log_artifact(self, local_path: str, artifact_path: str | None = None) -> None:
121
+ """Attach a local file to this solve.
122
+
123
+ Parameters
124
+ ----------
125
+ local_path : str
126
+ Path to an existing local file.
127
+ artifact_path : str, optional
128
+ Destination directory in run artifacts; if omitted, the file goes
129
+ to the artifact root.
130
+ """
131
+ try:
132
+ refusal: str | None = artifact_refusal(
133
+ local_path, enabled=self._options.log_artifacts
134
+ )
135
+ if refusal is not None:
136
+ warn(refusal)
137
+ return
138
+ self._artifacts.log_artifact(self.run_id, local_path, artifact_path)
139
+ except ArtifactStoreError as error:
140
+ warn(f"Could not log artifact: {error}")
141
+
142
+
143
+ _last_solve: ContextVar[SolveRun | None] = ContextVar("last_solve", default=None)
144
+
145
+
146
+ class RecordSolve:
147
+ """The use case that stores each completed solve as one run."""
148
+
149
+ def __init__(
150
+ self, runs: RunStore, artifacts: ArtifactStore, options: RecordOptions
151
+ ) -> None:
152
+ self._runs: RunStore = runs
153
+ self._artifacts: ArtifactStore = artifacts
154
+ self.options: RecordOptions = options
155
+
156
+ def begin(self) -> object:
157
+ """The parent run and annotations in effect at solve start."""
158
+ parent: RunReference | None
159
+ try:
160
+ parent = self._runs.active_run()
161
+ except RunStoreError as error:
162
+ warn(f"Could not get active run: {error}")
163
+ parent = None
164
+ return _RecordingContext(parent, current_annotation())
165
+
166
+ def record(self, solve: CompletedSolve, context: object) -> None:
167
+ """Store one completed solve."""
168
+ if not isinstance(context, _RecordingContext):
169
+ raise TypeError("Invalid recording context")
170
+ observation: SolveObservation | None = solve.observation
171
+ has_objective: bool | None = (
172
+ observation.metrics.has_objective if observation is not None else None
173
+ )
174
+ outcome: SolveOutcome = classify_outcome(
175
+ observation.outcome if observation else None,
176
+ solve.error,
177
+ has_objective=has_objective,
178
+ )
179
+ metrics: NormalizedMetrics = normalize_metrics(
180
+ observation.metrics if observation else None,
181
+ api=solve.api,
182
+ has_solution=outcome.has_solution,
183
+ wall_time_seconds=solve.wall_time_seconds,
184
+ include_native=self.options.log_solver_metrics,
185
+ )
186
+ metadata: CollectedMetadata = collect_metadata(
187
+ solve.api,
188
+ observation.metadata if observation else None,
189
+ include_parameters=self.options.log_solver_parameters,
190
+ )
191
+ tags: dict[str, str] = dict(annotation_tags(context.annotation))
192
+ tags.update(outcome_tags(outcome))
193
+ tags.update(metrics.tags)
194
+ tags.update(metadata.tags)
195
+ tags["optimization.solve_id"] = solve.solve_id
196
+ tags["optimization.solve_index"] = str(solve.solve_index)
197
+ if solve.error is not None:
198
+ tags["optimization.error"] = type(solve.error).__name__
199
+ status: RunStatus = RunStatus.FINISHED
200
+ if isinstance(solve.error, KeyboardInterrupt):
201
+ status = RunStatus.KILLED
202
+ elif isinstance(solve.error, Exception):
203
+ status = RunStatus.FAILED
204
+ solver: str = metadata.tags.get("optimization.solver") or "unknown"
205
+ record: SolveRecord = SolveRecord(
206
+ name=f"{solve.api}/{solver} #{solve.solve_index}",
207
+ status=status,
208
+ start_time_ms=solve.start_time_ms,
209
+ end_time_ms=solve.end_time_ms,
210
+ tags=tags,
211
+ metrics=metrics.values,
212
+ params=metadata.params,
213
+ )
214
+ run: RunReference
215
+ try:
216
+ run = self._runs.save(record, context.parent)
217
+ except RunStoreError as error:
218
+ warn(f"Could not record solve: {error}")
219
+ return
220
+ _last_solve.set(SolveRun(run, self._runs, self._artifacts, self.options))
221
+
222
+
223
+ def last_solve() -> SolveRun | None:
224
+ """The most recent solve recorded in the current thread or async context.
225
+
226
+ Returns
227
+ -------
228
+ SolveRun or None
229
+ None until a solve is recorded in the current context.
230
+ """
231
+ return _last_solve.get()
@@ -0,0 +1,43 @@
1
+ """The stored form of a solve."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class RunReference:
10
+ """The identity of a run in the experiment tracker."""
11
+
12
+ run_id: str
13
+ experiment_id: str
14
+
15
+
16
+ class RunStatus(StrEnum):
17
+ """The lifecycle state a solve's run ends in."""
18
+
19
+ FINISHED = "FINISHED"
20
+ FAILED = "FAILED"
21
+ KILLED = "KILLED"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SolveRecord:
26
+ """Everything stored for one solve, under final key names."""
27
+
28
+ name: str
29
+ status: RunStatus
30
+ start_time_ms: int
31
+ end_time_ms: int
32
+ tags: Mapping[str, str]
33
+ metrics: Mapping[str, float]
34
+ params: Mapping[str, str]
35
+
36
+
37
+ @dataclass
38
+ class RecordOptions:
39
+ """Which optional parts of a solve are recorded."""
40
+
41
+ log_solver_parameters: bool = True
42
+ log_solver_metrics: bool = True
43
+ log_artifacts: bool = True
@@ -0,0 +1,5 @@
1
+ """OR-Tools CP-SAT integration."""
2
+
3
+ from .adapter import CpSatAdapter
4
+
5
+ __all__ = ["CpSatAdapter"]