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,939 @@
1
+ """The MathOpt solver integration."""
2
+
3
+ import dataclasses
4
+ from collections.abc import Callable, Iterable, Mapping
5
+ from datetime import timedelta
6
+ from enum import Enum
7
+ from functools import wraps
8
+ from inspect import Signature, signature
9
+ from types import ModuleType
10
+ from typing import cast
11
+
12
+ import ortools
13
+ from ortools.math_opt.python import mathopt
14
+ from ortools.math_opt.python import solve as mathopt_solve
15
+ from ortools.math_opt.python.model import Model
16
+ from ortools.math_opt.python.objectives import Objective
17
+ from ortools.math_opt.python.parameters import SolveParameters, SolverType
18
+ from ortools.math_opt.python.result import SolveResult
19
+
20
+ from mlflow_optimization.common.patching import (
21
+ Patch,
22
+ rebind_module_names,
23
+ receiver_of,
24
+ restore_module_names,
25
+ )
26
+ from mlflow_optimization.features.autolog.ports import ObservationError, SolveObserver
27
+ from mlflow_optimization.features.autolog.types import SolveObservation
28
+ from mlflow_optimization.features.metadata.types import (
29
+ ParameterOrigin,
30
+ ReportedMetadata,
31
+ SolverParameter,
32
+ )
33
+ from mlflow_optimization.features.metrics.types import (
34
+ MissingReason,
35
+ ObjectiveSense,
36
+ ReportedMetrics,
37
+ )
38
+ from mlflow_optimization.features.outcomes.types import ReportedOutcome, Termination
39
+
40
+ ParameterValue = int | float | str | bool
41
+ _SDK_EXCEPTION: type[Exception] = Exception
42
+ _UNAVAILABLE: object = object()
43
+
44
+
45
+ class MathOptAdapter:
46
+ """The MathOpt integration hook."""
47
+
48
+ api: str = "mathopt"
49
+
50
+ def __init__(self) -> None:
51
+ self._patches: list[tuple[Patch, str]] = [
52
+ (Patch(mathopt, "solve"), "function"),
53
+ (Patch(mathopt_solve.IncrementalSolver, "solve"), "incremental"),
54
+ ]
55
+ self._rebound_module_names: list[tuple[ModuleType, str]] = []
56
+ self._module_solve_original: object | None = None
57
+ self._module_solve_replacement: object | None = None
58
+
59
+ def install(self, observer: SolveObserver) -> None:
60
+ """The installed MathOpt solve hooks."""
61
+ installed_patches: list[Patch] = []
62
+ original_module_solve: object = mathopt.solve
63
+ try:
64
+ for patch, entry_point in self._patches:
65
+
66
+ def create_wrapper(
67
+ original: object,
68
+ current_entry_point: str = entry_point,
69
+ ) -> object:
70
+ return self._wrap(original, observer, current_entry_point)
71
+
72
+ patch.install(create_wrapper)
73
+ installed_patches.append(patch)
74
+ replacement_module_solve: object = mathopt.solve
75
+ if replacement_module_solve is not original_module_solve:
76
+ self._rebound_module_names = rebind_module_names(
77
+ original_module_solve,
78
+ replacement_module_solve,
79
+ )
80
+ self._module_solve_original = original_module_solve
81
+ self._module_solve_replacement = replacement_module_solve
82
+ except (AttributeError, TypeError):
83
+ if self._module_solve_original is not None:
84
+ assert self._module_solve_replacement is not None
85
+ restore_module_names(
86
+ self._rebound_module_names,
87
+ self._module_solve_original,
88
+ self._module_solve_replacement,
89
+ )
90
+ self._rebound_module_names = []
91
+ self._module_solve_original = None
92
+ self._module_solve_replacement = None
93
+ for patch in reversed(installed_patches):
94
+ patch.uninstall()
95
+ raise
96
+
97
+ def uninstall(self) -> None:
98
+ """The restored MathOpt solve hooks."""
99
+ for patch, _ in reversed(self._patches):
100
+ patch.uninstall()
101
+ if self._module_solve_original is not None:
102
+ assert self._module_solve_replacement is not None
103
+ restore_module_names(
104
+ self._rebound_module_names,
105
+ self._module_solve_original,
106
+ self._module_solve_replacement,
107
+ )
108
+ self._rebound_module_names = []
109
+ self._module_solve_original = None
110
+ self._module_solve_replacement = None
111
+
112
+ def _wrap(
113
+ self, original: object, observer: SolveObserver, entry_point: str
114
+ ) -> object:
115
+ original_solve: Callable[..., SolveResult] = cast(
116
+ Callable[..., SolveResult], original
117
+ )
118
+
119
+ @wraps(original_solve)
120
+ def wrapped_solve(*args: object, **kwargs: object) -> SolveResult:
121
+ active_observer: SolveObserver = observer
122
+ receiver: object = (
123
+ receiver_of(original_solve, args, kwargs)
124
+ if entry_point == "incremental"
125
+ else _UNAVAILABLE
126
+ )
127
+ if entry_point == "incremental" and receiver is None:
128
+ return original_solve(*args, **kwargs)
129
+ call_signature: Signature = signature(original_solve)
130
+ call_signature.bind(*args, **kwargs)
131
+
132
+ def call() -> SolveResult:
133
+ return original_solve(*args, **kwargs)
134
+
135
+ def extract(
136
+ result: SolveResult | None,
137
+ error: BaseException | None,
138
+ ) -> SolveObservation:
139
+ return self._extract(
140
+ result,
141
+ error,
142
+ entry_point,
143
+ args,
144
+ cast(dict[str, object], kwargs),
145
+ receiver,
146
+ )
147
+
148
+ return active_observer.observe(self.api, call, extract)
149
+
150
+ return wrapped_solve
151
+
152
+ def _extract(
153
+ self,
154
+ result: SolveResult | None,
155
+ solve_error: BaseException | None,
156
+ entry_point: str,
157
+ call_arguments: tuple[object, ...],
158
+ call_keywords: Mapping[str, object],
159
+ receiver: object,
160
+ ) -> SolveObservation:
161
+ """The solver-independent snapshot of one MathOpt result."""
162
+ try:
163
+ safe_result: SolveResult | None = (
164
+ None if solve_error is not None else result
165
+ )
166
+ solver_type: SolverType | None = self._solver_type(
167
+ entry_point,
168
+ call_arguments,
169
+ call_keywords,
170
+ receiver,
171
+ )
172
+ model: Model | None = self._model(
173
+ entry_point, call_arguments, call_keywords, receiver
174
+ )
175
+ solve_parameters: SolveParameters | None = self._solve_parameters(
176
+ call_keywords
177
+ )
178
+ outcome: ReportedOutcome = self._outcome(safe_result, solve_error)
179
+ metrics: ReportedMetrics = self._metrics(
180
+ safe_result,
181
+ model,
182
+ solver_type,
183
+ solve_parameters,
184
+ outcome.has_solution,
185
+ )
186
+ metadata: ReportedMetadata = self._metadata(
187
+ solver_type, model, solve_parameters
188
+ )
189
+ return SolveObservation(self.api, outcome, metrics, metadata)
190
+ except _SDK_EXCEPTION as error:
191
+ raise ObservationError("MathOpt observation could not be built") from error
192
+
193
+ def _solver_type(
194
+ self,
195
+ entry_point: str,
196
+ call_arguments: tuple[object, ...],
197
+ call_keywords: Mapping[str, object],
198
+ receiver: object,
199
+ ) -> SolverType | None:
200
+ """The solver type supplied to a MathOpt entry point."""
201
+ if entry_point == "incremental":
202
+ incremental_solver_type: object = self._guarded_attribute(
203
+ receiver, "_solver_type"
204
+ )
205
+ return (
206
+ incremental_solver_type
207
+ if isinstance(incremental_solver_type, SolverType)
208
+ else None
209
+ )
210
+ solver_type: object = call_keywords.get("solver_type", _UNAVAILABLE)
211
+ if solver_type is _UNAVAILABLE and len(call_arguments) > 1:
212
+ solver_type = call_arguments[1]
213
+ return solver_type if isinstance(solver_type, SolverType) else None
214
+
215
+ def _model(
216
+ self,
217
+ entry_point: str,
218
+ call_arguments: tuple[object, ...],
219
+ call_keywords: Mapping[str, object],
220
+ receiver: object,
221
+ ) -> Model | None:
222
+ """The model supplied to a MathOpt entry point."""
223
+ if entry_point == "incremental":
224
+ incremental_model: object = self._guarded_attribute(receiver, "_model")
225
+ return incremental_model if isinstance(incremental_model, Model) else None
226
+ model: object = call_keywords.get("opt_model", _UNAVAILABLE)
227
+ if model is _UNAVAILABLE and call_arguments:
228
+ model = call_arguments[0]
229
+ return model if isinstance(model, Model) else None
230
+
231
+ def _solve_parameters(
232
+ self, call_keywords: Mapping[str, object]
233
+ ) -> SolveParameters | None:
234
+ """The explicit MathOpt solve parameters."""
235
+ solve_parameters: object = call_keywords.get("params")
236
+ return (
237
+ solve_parameters if isinstance(solve_parameters, SolveParameters) else None
238
+ )
239
+
240
+ def _outcome(
241
+ self,
242
+ result: SolveResult | None,
243
+ error: BaseException | None,
244
+ ) -> ReportedOutcome:
245
+ """The MathOpt termination status and primal solution availability."""
246
+ if error is not None:
247
+ error_termination: Termination = (
248
+ Termination.INTERRUPTED
249
+ if isinstance(error, KeyboardInterrupt)
250
+ else Termination.ERROR
251
+ )
252
+ return ReportedOutcome(type(error).__name__, error_termination, None)
253
+ if result is None:
254
+ return ReportedOutcome("unknown", Termination.UNKNOWN, None)
255
+ termination_value: object = self._guarded_attribute(result, "termination")
256
+ reason: object = self._guarded_attribute(termination_value, "reason")
257
+ limit: object = self._guarded_attribute(termination_value, "limit")
258
+ reason_name: str | None = self._enum_name(reason)
259
+ limit_name: str | None = self._enum_name(limit)
260
+ raw_status: str = self._raw_status(reason, limit, reason_name, limit_name)
261
+ termination: Termination = self._termination(reason_name, limit_name)
262
+ has_solution: bool | None = self._has_solution(result)
263
+ return ReportedOutcome(raw_status, termination, has_solution)
264
+
265
+ def _has_solution(self, result: SolveResult) -> bool | None:
266
+ """Whether a MathOpt result contains a primal feasible solution."""
267
+ try:
268
+ return result.has_primal_feasible_solution()
269
+ except _SDK_EXCEPTION:
270
+ return None
271
+
272
+ def _raw_status(
273
+ self,
274
+ reason: object,
275
+ limit: object,
276
+ reason_name: str | None,
277
+ limit_name: str | None,
278
+ ) -> str:
279
+ """The native MathOpt reason and limit names."""
280
+ if reason_name is None:
281
+ return "unknown"
282
+ reason_value: object = self._guarded_attribute(reason, "value")
283
+ raw_status: str = reason_name
284
+ if isinstance(reason_value, int):
285
+ raw_status = f"{raw_status} ({reason_value})"
286
+ if limit_name is not None:
287
+ limit_value: object = self._guarded_attribute(limit, "value")
288
+ raw_status = f"{raw_status}; limit={limit_name}"
289
+ if isinstance(limit_value, int):
290
+ raw_status = f"{raw_status} ({limit_value})"
291
+ return raw_status
292
+
293
+ def _termination(
294
+ self, reason_name: str | None, limit_name: str | None
295
+ ) -> Termination:
296
+ """The solver-independent termination of a MathOpt result."""
297
+ limit_terminations: dict[str, Termination] = {
298
+ "UNDETERMINED": Termination.OTHER_LIMIT,
299
+ "ITERATION": Termination.ITERATION_LIMIT,
300
+ "TIME": Termination.TIME_LIMIT,
301
+ "NODE": Termination.NODE_LIMIT,
302
+ "SOLUTION": Termination.SOLUTION_LIMIT,
303
+ "MEMORY": Termination.OTHER_LIMIT,
304
+ "OBJECTIVE": Termination.OTHER_LIMIT,
305
+ "NORM": Termination.OTHER_LIMIT,
306
+ "INTERRUPTED": Termination.INTERRUPTED,
307
+ "SLOW_PROGRESS": Termination.OTHER_LIMIT,
308
+ "OTHER": Termination.OTHER_LIMIT,
309
+ }
310
+ if reason_name in {"FEASIBLE", "NO_SOLUTION_FOUND"} and limit_name is not None:
311
+ return limit_terminations.get(limit_name, Termination.UNKNOWN)
312
+ reason_terminations: dict[str, Termination] = {
313
+ "OPTIMAL": Termination.OPTIMAL,
314
+ "INFEASIBLE": Termination.INFEASIBLE,
315
+ "UNBOUNDED": Termination.UNBOUNDED,
316
+ "INFEASIBLE_OR_UNBOUNDED": Termination.INFEASIBLE_OR_UNBOUNDED,
317
+ "IMPRECISE": Termination.UNKNOWN,
318
+ "FEASIBLE": Termination.FEASIBLE,
319
+ "NO_SOLUTION_FOUND": Termination.UNKNOWN,
320
+ "NUMERICAL_ERROR": Termination.ERROR,
321
+ "OTHER_ERROR": Termination.ERROR,
322
+ }
323
+ return reason_terminations.get(reason_name or "", Termination.UNKNOWN)
324
+
325
+ def _metrics(
326
+ self,
327
+ result: SolveResult | None,
328
+ model: Model | None,
329
+ solver_type: SolverType | None,
330
+ solve_parameters: SolveParameters | None,
331
+ has_solution: bool | None,
332
+ ) -> ReportedMetrics:
333
+ """The objective, bound, time, and statistics of a MathOpt result."""
334
+ has_objective: bool | None = self._has_objective(model)
335
+ sense: ObjectiveSense | None = self._objective_sense(model, has_objective)
336
+ if result is None:
337
+ objective_value: float | MissingReason = MissingReason.UNSUPPORTED
338
+ dual_bound: float | MissingReason = MissingReason.UNSUPPORTED
339
+ solver_time_seconds: float | MissingReason = MissingReason.UNSUPPORTED
340
+ else:
341
+ objective_value = self._objective_value(result, has_solution)
342
+ dual_bound = self._numeric_result_value(result)
343
+ solver_time_seconds = self._solver_time(result)
344
+ statistics: dict[str, float | MissingReason] = self._statistics(
345
+ result,
346
+ model,
347
+ solver_type,
348
+ solve_parameters,
349
+ )
350
+ native: dict[str, float] = self._native_statistics(result, solver_type)
351
+ return ReportedMetrics(
352
+ has_objective=has_objective,
353
+ sense=sense,
354
+ objective_value=objective_value,
355
+ dual_bound=dual_bound,
356
+ solver_time_seconds=solver_time_seconds,
357
+ statistics=statistics,
358
+ native=native,
359
+ )
360
+
361
+ def _has_objective(self, model: Model | None) -> bool | None:
362
+ """Whether a MathOpt objective has nonzero terms or an offset."""
363
+ if model is None:
364
+ return None
365
+ objective: Objective = cast(
366
+ Objective, self._guarded_attribute(model, "objective")
367
+ )
368
+ try:
369
+ linear_terms: object = tuple(objective.linear_terms())
370
+ except _SDK_EXCEPTION:
371
+ linear_terms = _UNAVAILABLE
372
+ try:
373
+ quadratic_terms: object = tuple(objective.quadratic_terms())
374
+ except _SDK_EXCEPTION:
375
+ quadratic_terms = _UNAVAILABLE
376
+ offset: object = self._guarded_attribute(objective, "offset")
377
+ if not isinstance(linear_terms, tuple) or not isinstance(
378
+ quadratic_terms, tuple
379
+ ):
380
+ return None
381
+ if not isinstance(offset, (int, float)):
382
+ return None
383
+ return bool(linear_terms or quadratic_terms or offset != 0.0)
384
+
385
+ def _objective_sense(
386
+ self,
387
+ model: Model | None,
388
+ has_objective: bool | None,
389
+ ) -> ObjectiveSense | None:
390
+ """The direction of a defined MathOpt objective."""
391
+ if model is None or has_objective is not True:
392
+ return None
393
+ objective: object = self._guarded_attribute(model, "objective")
394
+ is_maximize: object = self._guarded_attribute(objective, "is_maximize")
395
+ if not isinstance(is_maximize, bool):
396
+ return None
397
+ return ObjectiveSense.MAXIMIZE if is_maximize else ObjectiveSense.MINIMIZE
398
+
399
+ def _objective_value(
400
+ self,
401
+ result: SolveResult,
402
+ has_solution: bool | None,
403
+ ) -> float | MissingReason:
404
+ """The best primal feasible objective value in a MathOpt result."""
405
+ if has_solution is False:
406
+ return MissingReason.NO_SOLUTION
407
+ try:
408
+ value: object = result.objective_value()
409
+ except _SDK_EXCEPTION:
410
+ return MissingReason.FAILED
411
+ if isinstance(value, (int, float)):
412
+ return float(value)
413
+ return MissingReason.FAILED
414
+
415
+ def _numeric_result_value(
416
+ self,
417
+ result: SolveResult,
418
+ ) -> float | MissingReason:
419
+ """The dual bound from a MathOpt result."""
420
+ result_method: object = self._guarded_attribute(result, "dual_bound")
421
+ if not callable(result_method):
422
+ return MissingReason.UNSUPPORTED
423
+ try:
424
+ value: object = result_method()
425
+ except _SDK_EXCEPTION:
426
+ return MissingReason.FAILED
427
+ if isinstance(value, (int, float)):
428
+ return float(value)
429
+ return MissingReason.FAILED
430
+
431
+ def _solver_time(self, result: SolveResult) -> float | MissingReason:
432
+ """The solve time reported by MathOpt."""
433
+ solve_time_method: object = self._guarded_attribute(result, "solve_time")
434
+ if not callable(solve_time_method):
435
+ return MissingReason.UNSUPPORTED
436
+ try:
437
+ duration: object = solve_time_method()
438
+ except _SDK_EXCEPTION:
439
+ return MissingReason.FAILED
440
+ if isinstance(duration, timedelta):
441
+ return duration.total_seconds()
442
+ return MissingReason.FAILED
443
+
444
+ def _statistics(
445
+ self,
446
+ result: SolveResult | None,
447
+ model: Model | None,
448
+ solver_type: SolverType | None,
449
+ solve_parameters: SolveParameters | None,
450
+ ) -> dict[str, float | MissingReason]:
451
+ """The common search statistics reported by MathOpt."""
452
+ statistics: dict[str, float | MissingReason] = {
453
+ "node_count": MissingReason.UNSUPPORTED,
454
+ "simplex_iterations": MissingReason.UNSUPPORTED,
455
+ "barrier_iterations": MissingReason.UNSUPPORTED,
456
+ }
457
+ if result is None:
458
+ return statistics
459
+ supported_statistics: set[str] = set()
460
+ if solver_type is SolverType.GLOP:
461
+ supported_statistics.add("simplex_iterations")
462
+ elif solver_type is SolverType.GSCIP:
463
+ supported_statistics.update(
464
+ ("node_count", "simplex_iterations", "barrier_iterations")
465
+ )
466
+ elif solver_type is SolverType.HIGHS:
467
+ has_integer_variables: bool | None = self._has_integer_variables(model)
468
+ solves_relaxation: bool | None = self._highs_solves_relaxation(
469
+ solve_parameters
470
+ )
471
+ if solves_relaxation is True or (
472
+ solves_relaxation is False and has_integer_variables is False
473
+ ):
474
+ supported_statistics.update(
475
+ ("simplex_iterations", "barrier_iterations")
476
+ )
477
+ elif solves_relaxation is False and has_integer_variables is True:
478
+ supported_statistics.add("node_count")
479
+ zero_statistics_are_known: set[str] = (
480
+ supported_statistics
481
+ if self._statistics_availability_is_known(result, solve_parameters)
482
+ else set()
483
+ )
484
+ solve_stats: object = self._guarded_attribute(result, "solve_stats")
485
+ for statistic_name in statistics:
486
+ if statistic_name not in supported_statistics:
487
+ continue
488
+ value: object = self._guarded_attribute(solve_stats, statistic_name)
489
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
490
+ if value != 0 or statistic_name in zero_statistics_are_known:
491
+ statistics[statistic_name] = float(value)
492
+ elif value is _UNAVAILABLE and statistic_name in zero_statistics_are_known:
493
+ statistics[statistic_name] = MissingReason.FAILED
494
+ return statistics
495
+
496
+ def _statistics_availability_is_known(
497
+ self,
498
+ result: SolveResult,
499
+ solve_parameters: SolveParameters | None,
500
+ ) -> bool:
501
+ """The MathOpt solve states that establish work-count availability."""
502
+ termination: object = self._guarded_attribute(result, "termination")
503
+ reason: object = self._guarded_attribute(termination, "reason")
504
+ limit: object = self._guarded_attribute(termination, "limit")
505
+ reason_name: str | None = self._enum_name(reason)
506
+ limit_name: str | None = self._enum_name(limit)
507
+ if self._has_solution(result) is True:
508
+ return True
509
+ if limit is None and reason_name in {
510
+ "OPTIMAL",
511
+ "INFEASIBLE",
512
+ "UNBOUNDED",
513
+ "INFEASIBLE_OR_UNBOUNDED",
514
+ }:
515
+ return True
516
+ time_limit: object = self._guarded_attribute(solve_parameters, "time_limit")
517
+ return limit_name == "TIME" and time_limit == timedelta(0)
518
+
519
+ def _has_integer_variables(self, model: Model | None) -> bool | None:
520
+ """Whether a MathOpt model contains integer variables."""
521
+ if model is None:
522
+ return None
523
+ variables_method: object = self._guarded_attribute(model, "variables")
524
+ if not callable(variables_method):
525
+ return None
526
+ try:
527
+ variables: tuple[object, ...] = tuple(
528
+ cast(Iterable[object], variables_method())
529
+ )
530
+ except _SDK_EXCEPTION:
531
+ return None
532
+ for variable in variables:
533
+ integer_flag: object = self._guarded_attribute(variable, "integer")
534
+ if not isinstance(integer_flag, bool):
535
+ return None
536
+ if integer_flag:
537
+ return True
538
+ return False
539
+
540
+ def _highs_solves_relaxation(
541
+ self, solve_parameters: SolveParameters | None
542
+ ) -> bool | None:
543
+ """Whether HiGHS is configured to solve the relaxation of a MIP."""
544
+ if solve_parameters is None:
545
+ return False
546
+ highs_parameters: object = self._guarded_attribute(
547
+ solve_parameters,
548
+ "highs",
549
+ )
550
+ boolean_options: object = self._guarded_attribute(
551
+ highs_parameters,
552
+ "bool_options",
553
+ )
554
+ if not isinstance(boolean_options, Mapping):
555
+ return None
556
+ try:
557
+ solves_relaxation: object = boolean_options.get("solve_relaxation", False)
558
+ except _SDK_EXCEPTION:
559
+ return None
560
+ return solves_relaxation if isinstance(solves_relaxation, bool) else None
561
+
562
+ def _native_statistics(
563
+ self,
564
+ result: SolveResult | None,
565
+ solver_type: SolverType | None,
566
+ ) -> dict[str, float]:
567
+ """The MathOpt backend-specific numeric statistics."""
568
+ native: dict[str, float] = {}
569
+ if result is None:
570
+ return native
571
+ solve_stats: object = self._guarded_attribute(result, "solve_stats")
572
+ if solver_type is SolverType.PDLP:
573
+ first_order_iterations: object = self._guarded_attribute(
574
+ solve_stats,
575
+ "first_order_iterations",
576
+ )
577
+ if isinstance(first_order_iterations, (int, float)) and not isinstance(
578
+ first_order_iterations,
579
+ bool,
580
+ ):
581
+ native["first_order_iterations"] = float(first_order_iterations)
582
+ for output_name in (
583
+ "gscip_specific_output",
584
+ "osqp_specific_output",
585
+ "pdlp_specific_output",
586
+ ):
587
+ output: object = self._guarded_attribute(result, output_name)
588
+ if output is not None and output is not _UNAVAILABLE:
589
+ self._collect_native_values(
590
+ output_name.removesuffix("_specific_output"), output, native
591
+ )
592
+ return native
593
+
594
+ def _collect_native_values(
595
+ self,
596
+ prefix: str,
597
+ message: object,
598
+ native: dict[str, float],
599
+ ) -> None:
600
+ """The numeric leaves in a MathOpt backend output message."""
601
+ list_fields_method: object = self._guarded_attribute(message, "ListFields")
602
+ if not callable(list_fields_method):
603
+ return
604
+ try:
605
+ fields: object = list_fields_method()
606
+ except _SDK_EXCEPTION:
607
+ return
608
+ if not isinstance(fields, list):
609
+ return
610
+ for field_descriptor, field_value in fields:
611
+ field_name: object = self._guarded_attribute(field_descriptor, "name")
612
+ enum_descriptor: object = self._guarded_attribute(
613
+ field_descriptor, "enum_type"
614
+ )
615
+ if not isinstance(field_name, str):
616
+ continue
617
+ if enum_descriptor is not None and enum_descriptor is not _UNAVAILABLE:
618
+ continue
619
+ if self._is_message(field_value):
620
+ self._collect_native_values(
621
+ f"{prefix}.{field_name}", field_value, native
622
+ )
623
+ elif isinstance(field_value, (int, float)) and not isinstance(
624
+ field_value, bool
625
+ ):
626
+ native_name: str = f"{prefix}.{field_name}"
627
+ native[native_name] = float(field_value)
628
+
629
+ def _metadata(
630
+ self,
631
+ solver_type: SolverType | None,
632
+ model: Model | None,
633
+ solve_parameters: SolveParameters | None,
634
+ ) -> ReportedMetadata:
635
+ """The engine, version, problem size, and explicit parameters of a MathOpt solve."""
636
+ solver_names: dict[str, str] = {
637
+ "GSCIP": "scip",
638
+ "GUROBI": "gurobi",
639
+ "GLOP": "glop",
640
+ "CP_SAT": "cp_sat",
641
+ "PDLP": "pdlp",
642
+ "GLPK": "glpk",
643
+ "OSQP": "osqp",
644
+ "ECOS": "ecos",
645
+ "SCS": "scs",
646
+ "HIGHS": "highs",
647
+ "SANTORINI": "santorini",
648
+ }
649
+ solver_name: str | None = None
650
+ solver_type_name: str | None = self._enum_name(solver_type)
651
+ if solver_type_name is not None:
652
+ solver_name = solver_names.get(solver_type_name)
653
+ problem: dict[str, int | float | str | bool] = self._problem_size(model)
654
+ parameters: list[SolverParameter] = self._parameters(
655
+ solve_parameters,
656
+ solver_type,
657
+ )
658
+ api_version: object = self._guarded_attribute(ortools, "__version__")
659
+ return ReportedMetadata(
660
+ solver=solver_name,
661
+ api_version=api_version if isinstance(api_version, str) else None,
662
+ solver_version=None,
663
+ problem=problem,
664
+ parameters=parameters,
665
+ wall_time_includes_problem_build=False,
666
+ )
667
+
668
+ def _problem_size(self, model: Model | None) -> dict[str, int | float | str | bool]:
669
+ """The dimensions of a MathOpt model."""
670
+ problem: dict[str, int | float | str | bool] = {}
671
+ if model is None:
672
+ return problem
673
+ count_methods: tuple[tuple[str, str], ...] = (
674
+ ("num_variables", "get_num_variables"),
675
+ ("num_linear_constraints", "get_num_linear_constraints"),
676
+ ("num_quadratic_constraints", "get_num_quadratic_constraints"),
677
+ ("num_indicator_constraints", "get_num_indicator_constraints"),
678
+ )
679
+ constraint_counts: list[int] = []
680
+ for problem_name, method_name in count_methods:
681
+ count_method: object = self._guarded_attribute(model, method_name)
682
+ if not callable(count_method):
683
+ continue
684
+ try:
685
+ count: object = count_method()
686
+ except _SDK_EXCEPTION:
687
+ continue
688
+ if isinstance(count, int) and not isinstance(count, bool):
689
+ problem[problem_name] = count
690
+ if problem_name != "num_variables":
691
+ constraint_counts.append(count)
692
+ if len(constraint_counts) == 3:
693
+ problem["num_constraints"] = sum(constraint_counts)
694
+ variables_method: object = self._guarded_attribute(model, "variables")
695
+ if not callable(variables_method):
696
+ return problem
697
+ try:
698
+ variables: object = tuple(cast(Iterable[object], variables_method()))
699
+ except _SDK_EXCEPTION:
700
+ return problem
701
+ if isinstance(variables, tuple):
702
+ integer_count: int = 0
703
+ all_integer_flags_available: bool = True
704
+ for variable in variables:
705
+ integer_flag: object = self._guarded_attribute(variable, "integer")
706
+ if isinstance(integer_flag, bool):
707
+ integer_count += int(integer_flag)
708
+ else:
709
+ all_integer_flags_available = False
710
+ if all_integer_flags_available:
711
+ problem["num_integer_variables"] = integer_count
712
+ return problem
713
+
714
+ def _parameters(
715
+ self,
716
+ solve_parameters: SolveParameters | None,
717
+ solver_type: SolverType | None,
718
+ ) -> list[SolverParameter]:
719
+ """The solve parameters that differ from MathOpt defaults."""
720
+ parameters: list[SolverParameter] = []
721
+ if solve_parameters is None:
722
+ return parameters
723
+ backend_parameter_groups: dict[str, SolverType] = {
724
+ "gscip": SolverType.GSCIP,
725
+ "gurobi": SolverType.GUROBI,
726
+ "glop": SolverType.GLOP,
727
+ "cp_sat": SolverType.CP_SAT,
728
+ "pdlp": SolverType.PDLP,
729
+ "osqp": SolverType.OSQP,
730
+ "glpk": SolverType.GLPK,
731
+ "highs": SolverType.HIGHS,
732
+ }
733
+ default_parameters: SolveParameters = SolveParameters()
734
+ for parameter_field in dataclasses.fields(SolveParameters):
735
+ parameter_name: str = parameter_field.name
736
+ backend_solver_type: SolverType | None = backend_parameter_groups.get(
737
+ parameter_name
738
+ )
739
+ if (
740
+ backend_solver_type is not None
741
+ and backend_solver_type is not solver_type
742
+ ):
743
+ continue
744
+ parameter_value: object = self._guarded_attribute(
745
+ solve_parameters, parameter_name
746
+ )
747
+ default_value: object = self._guarded_attribute(
748
+ default_parameters, parameter_name
749
+ )
750
+ if parameter_value is _UNAVAILABLE or default_value is _UNAVAILABLE:
751
+ continue
752
+ is_default: bool = True
753
+ try:
754
+ is_default = parameter_value == default_value
755
+ except _SDK_EXCEPTION:
756
+ is_default = True
757
+ if is_default or parameter_value is None:
758
+ continue
759
+ self._append_parameters(
760
+ parameter_name,
761
+ parameter_value,
762
+ default_value,
763
+ parameters,
764
+ parameter_name == "gurobi",
765
+ )
766
+ return parameters
767
+
768
+ def _append_parameters(
769
+ self,
770
+ parameter_name: str,
771
+ parameter_value: object,
772
+ default_value: object,
773
+ parameters: list[SolverParameter],
774
+ suppress_strings: bool,
775
+ ) -> None:
776
+ """The differing nested MathOpt parameter values."""
777
+ if parameter_value is None or parameter_value is _UNAVAILABLE:
778
+ return
779
+ try:
780
+ is_default: bool = parameter_value == default_value
781
+ except _SDK_EXCEPTION:
782
+ return
783
+ if is_default:
784
+ return
785
+ if suppress_strings and isinstance(parameter_value, str):
786
+ return
787
+ if isinstance(parameter_value, Mapping):
788
+ default_mapping: Mapping[object, object] = (
789
+ default_value if isinstance(default_value, Mapping) else {}
790
+ )
791
+ mapping_items: tuple[tuple[object, object], ...]
792
+ try:
793
+ mapping_items = tuple(parameter_value.items())
794
+ except _SDK_EXCEPTION:
795
+ return
796
+ for mapping_name, nested_value in mapping_items:
797
+ if not isinstance(mapping_name, str):
798
+ continue
799
+ try:
800
+ nested_default: object = default_mapping.get(
801
+ mapping_name,
802
+ _UNAVAILABLE,
803
+ )
804
+ except _SDK_EXCEPTION:
805
+ nested_default = _UNAVAILABLE
806
+ self._append_parameters(
807
+ f"{parameter_name}.{mapping_name}",
808
+ nested_value,
809
+ nested_default,
810
+ parameters,
811
+ suppress_strings,
812
+ )
813
+ return
814
+ if dataclasses.is_dataclass(parameter_value) and not isinstance(
815
+ parameter_value, type
816
+ ):
817
+ default_object: object = default_value
818
+ try:
819
+ nested_fields: tuple[object, ...] = tuple(
820
+ dataclasses.fields(parameter_value)
821
+ )
822
+ except _SDK_EXCEPTION:
823
+ return
824
+ for parameter_field in nested_fields:
825
+ nested_name: str = cast(str, parameter_field.name)
826
+ nested_value: object = self._guarded_attribute(
827
+ parameter_value, nested_name
828
+ )
829
+ field_default: object = self._guarded_attribute(
830
+ default_object, nested_name
831
+ )
832
+ self._append_parameters(
833
+ f"{parameter_name}.{nested_name}",
834
+ nested_value,
835
+ field_default,
836
+ parameters,
837
+ suppress_strings,
838
+ )
839
+ return
840
+ if self._is_message(parameter_value):
841
+ list_fields_method: object = self._guarded_attribute(
842
+ parameter_value, "ListFields"
843
+ )
844
+ if not callable(list_fields_method):
845
+ return
846
+ try:
847
+ fields: object = list_fields_method()
848
+ except _SDK_EXCEPTION:
849
+ return
850
+ if not isinstance(fields, list):
851
+ return
852
+ for field_descriptor, field_value in fields:
853
+ field_name: object = self._guarded_attribute(field_descriptor, "name")
854
+ enum_descriptor: object = self._guarded_attribute(
855
+ field_descriptor, "enum_type"
856
+ )
857
+ if not isinstance(field_name, str):
858
+ continue
859
+ if enum_descriptor is not None and enum_descriptor is not _UNAVAILABLE:
860
+ enum_values: object = self._guarded_attribute(
861
+ enum_descriptor,
862
+ "values_by_number",
863
+ )
864
+ if not isinstance(enum_values, Mapping):
865
+ continue
866
+ enum_value: object = _UNAVAILABLE
867
+ try:
868
+ enum_value = enum_values[field_value]
869
+ except _SDK_EXCEPTION:
870
+ pass
871
+ if enum_value is _UNAVAILABLE:
872
+ continue
873
+ enum_name: object = self._guarded_attribute(enum_value, "name")
874
+ if isinstance(enum_name, str):
875
+ parameters.append(
876
+ SolverParameter(
877
+ f"{parameter_name}.{field_name}",
878
+ enum_name,
879
+ ParameterOrigin.EXPLICIT,
880
+ )
881
+ )
882
+ else:
883
+ self._append_parameters(
884
+ f"{parameter_name}.{field_name}",
885
+ field_value,
886
+ _UNAVAILABLE,
887
+ parameters,
888
+ suppress_strings,
889
+ )
890
+ return
891
+ scalar: ParameterValue | None = self._parameter_value(parameter_value)
892
+ if scalar is not None:
893
+ parameters.append(
894
+ SolverParameter(parameter_name, scalar, ParameterOrigin.EXPLICIT)
895
+ )
896
+ return
897
+ try:
898
+ serialized_value: str = str(parameter_value)
899
+ except _SDK_EXCEPTION:
900
+ return
901
+ if suppress_strings:
902
+ return
903
+ parameters.append(
904
+ SolverParameter(parameter_name, serialized_value, ParameterOrigin.EXPLICIT)
905
+ )
906
+
907
+ def _parameter_value(self, parameter_value: object) -> ParameterValue | None:
908
+ """The scalar form of a MathOpt parameter."""
909
+ if isinstance(parameter_value, bool):
910
+ return parameter_value
911
+ if isinstance(parameter_value, (int, float, str)):
912
+ return parameter_value
913
+ if isinstance(parameter_value, timedelta):
914
+ return parameter_value.total_seconds()
915
+ if isinstance(parameter_value, Enum):
916
+ enum_name: object = self._guarded_attribute(parameter_value, "name")
917
+ return enum_name if isinstance(enum_name, str) else None
918
+ return None
919
+
920
+ def _is_message(self, candidate: object) -> bool:
921
+ """Whether a value is a protobuf message."""
922
+ list_fields_method: object = self._guarded_attribute(candidate, "ListFields")
923
+ return callable(list_fields_method)
924
+
925
+ def _enum_name(self, candidate: object) -> str | None:
926
+ """The name of a known MathOpt enum value."""
927
+ enum_name: object = self._guarded_attribute(candidate, "name")
928
+ return enum_name if isinstance(enum_name, str) else None
929
+
930
+ def _guarded_attribute(
931
+ self, attribute_owner: object, attribute_name: str
932
+ ) -> object:
933
+ """The optional attribute value when the read succeeds."""
934
+ if attribute_owner is None or attribute_owner is _UNAVAILABLE:
935
+ return _UNAVAILABLE
936
+ try:
937
+ return getattr(attribute_owner, attribute_name)
938
+ except _SDK_EXCEPTION:
939
+ return _UNAVAILABLE