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,5 @@
1
+ """The PySCIPOpt solver integration package."""
2
+
3
+ from .adapter import PyScipOptAdapter
4
+
5
+ __all__ = ["PyScipOptAdapter"]
@@ -0,0 +1,657 @@
1
+ """The PySCIPOpt solver integration."""
2
+
3
+ import math
4
+ from collections.abc import Callable
5
+ from functools import wraps
6
+ from inspect import Signature, signature
7
+ from types import ModuleType
8
+ from typing import Any
9
+ from weakref import WeakKeyDictionary
10
+
11
+ import pyscipopt
12
+ import pyscipopt.scip
13
+ from mlflow_optimization.common.patching import (
14
+ rebind_module_names,
15
+ receiver_of,
16
+ restore_module_names,
17
+ )
18
+ from mlflow_optimization.features.autolog.ports import ObservationError, SolveObserver
19
+ from mlflow_optimization.features.autolog.types import SolveObservation
20
+ from mlflow_optimization.features.metadata.types import (
21
+ ParameterOrigin,
22
+ ReportedMetadata,
23
+ SolverParameter,
24
+ )
25
+ from mlflow_optimization.features.metrics.types import (
26
+ MissingReason,
27
+ ObjectiveSense,
28
+ ReportedMetrics,
29
+ )
30
+ from mlflow_optimization.features.outcomes.types import ReportedOutcome, Termination
31
+
32
+ _STATUS_TERMINATIONS: dict[str, Termination] = {
33
+ "unknown": Termination.UNKNOWN,
34
+ "optimal": Termination.OPTIMAL,
35
+ "infeasible": Termination.INFEASIBLE,
36
+ "unbounded": Termination.UNBOUNDED,
37
+ "inforunbd": Termination.INFEASIBLE_OR_UNBOUNDED,
38
+ "userinterrupt": Termination.INTERRUPTED,
39
+ "terminate": Termination.INTERRUPTED,
40
+ "nodelimit": Termination.NODE_LIMIT,
41
+ "totalnodelimit": Termination.NODE_LIMIT,
42
+ "stallnodelimit": Termination.NODE_LIMIT,
43
+ "timelimit": Termination.TIME_LIMIT,
44
+ "memlimit": Termination.OTHER_LIMIT,
45
+ "gaplimit": Termination.OTHER_LIMIT,
46
+ "primallimit": Termination.OTHER_LIMIT,
47
+ "duallimit": Termination.OTHER_LIMIT,
48
+ "sollimit": Termination.SOLUTION_LIMIT,
49
+ "bestsollimit": Termination.SOLUTION_LIMIT,
50
+ "restartlimit": Termination.OTHER_LIMIT,
51
+ }
52
+
53
+
54
+ class PyScipOptAdapter:
55
+ """The PySCIPOpt solver integration."""
56
+
57
+ api: str = "pyscipopt"
58
+
59
+ def __init__(self) -> None:
60
+ self._installed: bool = False
61
+ self._observer: SolveObserver | None = None
62
+ self._original_model: type[Any] | None = None
63
+ self._logging_model: type[Any] | None = None
64
+ self._rebound_names: list[tuple[ModuleType, str]] = []
65
+ self._counter_snapshots: WeakKeyDictionary[
66
+ Any,
67
+ tuple[int | MissingReason, dict[str, float | MissingReason]],
68
+ ] = WeakKeyDictionary()
69
+
70
+ def install(self, observer: SolveObserver) -> None:
71
+ """A patch that replaces model references with an observing subclass."""
72
+ if self._installed:
73
+ self._observer = observer
74
+ return
75
+
76
+ original_model: type[Any] = pyscipopt.Model
77
+ integration: PyScipOptAdapter = self
78
+ default_parameters: dict[str, bool | int | float | str] | None = (
79
+ _read_default_parameters(original_model)
80
+ )
81
+
82
+ class LoggingModel(original_model):
83
+ """A PySCIPOpt model that observes optimization calls."""
84
+
85
+ entry_point_names: tuple[str, ...] = (
86
+ "optimize",
87
+ "optimizeNogil",
88
+ "solveConcurrent",
89
+ )
90
+ for entry_point_name in entry_point_names:
91
+ original_entry_point: Any = getattr(original_model, entry_point_name, None)
92
+ if not callable(original_entry_point):
93
+ continue
94
+ observed_entry_point: Callable[..., Any] = _wrap_solve_entry_point(
95
+ original_entry_point,
96
+ integration,
97
+ default_parameters,
98
+ )
99
+ setattr(LoggingModel, entry_point_name, observed_entry_point)
100
+
101
+ logging_model: type[Any] = LoggingModel
102
+ rebound_names: list[tuple[ModuleType, str]] = rebind_module_names(
103
+ original_model,
104
+ logging_model,
105
+ )
106
+
107
+ self._observer = observer
108
+ self._original_model = original_model
109
+ self._logging_model = logging_model
110
+ self._rebound_names = rebound_names
111
+ self._installed = True
112
+
113
+ def uninstall(self) -> None:
114
+ """The restoration of original PySCIPOpt model references."""
115
+ if not self._installed:
116
+ return
117
+
118
+ assert self._original_model is not None
119
+ assert self._logging_model is not None
120
+ original_model: type[Any] = self._original_model
121
+ logging_model: type[Any] = self._logging_model
122
+ restore_module_names(self._rebound_names, original_model, logging_model)
123
+ rebind_module_names(logging_model, original_model)
124
+
125
+ self._installed = False
126
+ self._observer = None
127
+ self._original_model = None
128
+ self._logging_model = None
129
+ self._rebound_names = []
130
+ self._counter_snapshots.clear()
131
+
132
+
133
+ def _wrap_solve_entry_point(
134
+ original_entry_point: Callable[..., Any],
135
+ integration: PyScipOptAdapter,
136
+ default_parameters: dict[str, bool | int | float | str] | None,
137
+ ) -> Callable[..., Any]:
138
+ """An observed wrapper for one original PySCIPOpt solve entry point."""
139
+ original_signature: Signature = signature(original_entry_point)
140
+
141
+ @wraps(original_entry_point)
142
+ def observed_entry_point(
143
+ *args: Any,
144
+ **kwargs: Any,
145
+ ) -> Any:
146
+ observer: SolveObserver | None = integration._observer
147
+ model: Any = receiver_of(original_entry_point, args, kwargs)
148
+ if model is None or observer is None:
149
+ return original_entry_point(*args, **kwargs)
150
+ original_signature.bind(*args, **kwargs)
151
+ counter_snapshots: WeakKeyDictionary[
152
+ Any,
153
+ tuple[int | MissingReason, dict[str, float | MissingReason]],
154
+ ] = integration._counter_snapshots
155
+ stage_before_solve: int | MissingReason = _read_value(
156
+ lambda: int(model.getStage()),
157
+ MissingReason.FAILED,
158
+ )
159
+ counter_snapshot: (
160
+ tuple[
161
+ int | MissingReason,
162
+ dict[str, float | MissingReason],
163
+ ]
164
+ | None
165
+ ) = None
166
+ counter_cache_available: bool = True
167
+ try:
168
+ counter_snapshot = counter_snapshots.get(model)
169
+ except Exception: # noqa: BLE001
170
+ counter_cache_available = False
171
+ counters_before_solve: dict[str, float | MissingReason] | None = None
172
+ if not counter_cache_available or isinstance(
173
+ stage_before_solve,
174
+ MissingReason,
175
+ ):
176
+ counters_before_solve = _unavailable_solve_counters()
177
+ elif counter_snapshot is None:
178
+ if stage_before_solve != pyscipopt.scip.PY_SCIP_STAGE.PROBLEM:
179
+ counters_before_solve = _unavailable_solve_counters()
180
+ else:
181
+ previous_stage: int | MissingReason = counter_snapshot[0]
182
+ if isinstance(previous_stage, MissingReason):
183
+ counters_before_solve = _unavailable_solve_counters()
184
+ elif stage_before_solve == previous_stage:
185
+ counters_before_solve = counter_snapshot[1]
186
+ elif stage_before_solve != pyscipopt.scip.PY_SCIP_STAGE.PROBLEM:
187
+ counters_before_solve = _unavailable_solve_counters()
188
+
189
+ # NOTE: SCIP changes parameters while solving, so capture configured values first.
190
+ parameters_before_solve: tuple[SolverParameter, ...] = _read_parameters(
191
+ model,
192
+ default_parameters,
193
+ )
194
+
195
+ def call() -> Any:
196
+ return original_entry_point(*args, **kwargs)
197
+
198
+ def extract(
199
+ result: Any | None, error: BaseException | None
200
+ ) -> SolveObservation:
201
+ try:
202
+ counters_after_solve: dict[str, float | MissingReason] | None = None
203
+ if error is None:
204
+ counters_after_solve = _read_solve_counters(model)
205
+ stage_after_solve: int | MissingReason = _read_value(
206
+ lambda: int(model.getStage()),
207
+ MissingReason.FAILED,
208
+ )
209
+ try:
210
+ counter_snapshots[model] = (
211
+ stage_after_solve,
212
+ counters_after_solve,
213
+ )
214
+ except Exception: # noqa: BLE001
215
+ counter_snapshots.clear()
216
+ else:
217
+ try:
218
+ counter_snapshots[model] = (
219
+ MissingReason.FAILED,
220
+ _unavailable_solve_counters(),
221
+ )
222
+ except Exception: # noqa: BLE001
223
+ counter_snapshots.clear()
224
+ return _extract_observation(
225
+ model,
226
+ error,
227
+ parameters_before_solve,
228
+ counters_before_solve,
229
+ counters_after_solve,
230
+ )
231
+ except Exception as extraction_error:
232
+ raise ObservationError(
233
+ "Could not build a PySCIPOpt solve observation."
234
+ ) from extraction_error
235
+
236
+ return observer.observe(integration.api, call, extract)
237
+
238
+ return observed_entry_point
239
+
240
+
241
+ def _extract_observation(
242
+ model: Any,
243
+ error: BaseException | None,
244
+ parameters_before_solve: tuple[SolverParameter, ...],
245
+ counters_before_solve: dict[str, float | MissingReason] | None,
246
+ counters_after_solve: dict[str, float | MissingReason] | None,
247
+ ) -> SolveObservation:
248
+ """The SDK-free observation of one PySCIPOpt optimization call."""
249
+ if error is not None:
250
+ error_termination: Termination = (
251
+ Termination.INTERRUPTED
252
+ if isinstance(error, KeyboardInterrupt)
253
+ else Termination.ERROR
254
+ )
255
+ error_metrics: ReportedMetrics = ReportedMetrics(
256
+ has_objective=None,
257
+ sense=None,
258
+ objective_value=MissingReason.FAILED,
259
+ dual_bound=MissingReason.FAILED,
260
+ solver_time_seconds=MissingReason.FAILED,
261
+ statistics={},
262
+ native={},
263
+ )
264
+ error_metadata: ReportedMetadata = _read_metadata(
265
+ model, parameters_before_solve
266
+ )
267
+ error_outcome: ReportedOutcome = ReportedOutcome(
268
+ raw_status="unknown",
269
+ termination=error_termination,
270
+ has_solution=None,
271
+ )
272
+ return SolveObservation(
273
+ api="pyscipopt",
274
+ outcome=error_outcome,
275
+ metrics=error_metrics,
276
+ metadata=error_metadata,
277
+ )
278
+
279
+ raw_status: str = _read_value(lambda: str(model.getStatus()), "unknown")
280
+ has_solution: bool | None = _read_value(lambda: model.getNSols() > 0, None)
281
+ termination: Termination = _STATUS_TERMINATIONS.get(
282
+ raw_status.lower(), Termination.UNKNOWN
283
+ )
284
+
285
+ has_objective: bool | None = _read_has_objective(model)
286
+ sense_name: str | MissingReason = _read_value(
287
+ lambda: str(model.getObjectiveSense()).lower(),
288
+ MissingReason.FAILED,
289
+ )
290
+ sense: ObjectiveSense | None = (
291
+ {
292
+ "minimize": ObjectiveSense.MINIMIZE,
293
+ "maximize": ObjectiveSense.MAXIMIZE,
294
+ }.get(sense_name)
295
+ if isinstance(sense_name, str)
296
+ else None
297
+ )
298
+
299
+ gap: float | MissingReason | None = None
300
+ if has_objective is False:
301
+ objective_value: float | MissingReason = MissingReason.UNDEFINED
302
+ dual_bound: float | MissingReason = MissingReason.UNDEFINED
303
+ else:
304
+ solver_infinity: float | MissingReason = _read_number(
305
+ lambda: float(model.infinity())
306
+ )
307
+ dual_bound = _read_scip_number(
308
+ lambda: float(model.getDualbound()),
309
+ solver_infinity,
310
+ )
311
+ if has_solution is False:
312
+ objective_value = MissingReason.NO_SOLUTION
313
+ else:
314
+ objective_value = _read_scip_number(
315
+ lambda: float(model.getObjVal()),
316
+ solver_infinity,
317
+ )
318
+ if has_solution is not False:
319
+ gap = _read_scip_number(
320
+ lambda: float(model.getGap()),
321
+ solver_infinity,
322
+ )
323
+
324
+ assert counters_after_solve is not None
325
+ solver_time_seconds: float | MissingReason = _counter_difference(
326
+ counters_after_solve["solver_time_seconds"],
327
+ counters_before_solve,
328
+ "solver_time_seconds",
329
+ )
330
+ node_count: float | MissingReason = _counter_difference(
331
+ counters_after_solve["node_count"],
332
+ counters_before_solve,
333
+ "node_count",
334
+ )
335
+ statistics: dict[str, float | MissingReason] = {"node_count": node_count}
336
+ native: dict[str, float] = {}
337
+
338
+ _add_native_counter(
339
+ native,
340
+ "solution_count",
341
+ counters_after_solve["solution_count"],
342
+ counters_before_solve,
343
+ )
344
+ _add_native_counter(
345
+ native,
346
+ "solutions_found",
347
+ counters_after_solve["solutions_found"],
348
+ counters_before_solve,
349
+ )
350
+ _add_native_counter(
351
+ native,
352
+ "lp_count",
353
+ counters_after_solve["lp_count"],
354
+ counters_before_solve,
355
+ )
356
+ _add_native_counter(
357
+ native,
358
+ "lp_iterations",
359
+ counters_after_solve["lp_iterations"],
360
+ counters_before_solve,
361
+ )
362
+ _add_native_counter(
363
+ native,
364
+ "total_node_count",
365
+ counters_after_solve["total_node_count"],
366
+ counters_before_solve,
367
+ )
368
+ _add_native_counter(
369
+ native,
370
+ "presolving_time_seconds",
371
+ counters_after_solve["presolving_time_seconds"],
372
+ counters_before_solve,
373
+ )
374
+ if gap is not None and not isinstance(gap, MissingReason):
375
+ native["gap"] = gap
376
+
377
+ metrics: ReportedMetrics = ReportedMetrics(
378
+ has_objective=has_objective,
379
+ sense=sense,
380
+ objective_value=objective_value,
381
+ dual_bound=dual_bound,
382
+ solver_time_seconds=solver_time_seconds,
383
+ statistics=statistics,
384
+ native=native,
385
+ )
386
+ metadata: ReportedMetadata = _read_metadata(model, parameters_before_solve)
387
+ outcome: ReportedOutcome = ReportedOutcome(
388
+ raw_status=raw_status,
389
+ termination=termination,
390
+ has_solution=has_solution,
391
+ )
392
+
393
+ return SolveObservation(
394
+ api="pyscipopt", outcome=outcome, metrics=metrics, metadata=metadata
395
+ )
396
+
397
+
398
+ def _read_has_objective(model: Any) -> bool | None:
399
+ """Whether the model has a nonzero objective function."""
400
+ objective_terms_present: bool | MissingReason = _read_value(
401
+ lambda: bool(model.getObjective().terms),
402
+ MissingReason.FAILED,
403
+ )
404
+ objective_offset: float | MissingReason = _read_value(
405
+ lambda: float(model.getObjoffset()),
406
+ MissingReason.FAILED,
407
+ )
408
+
409
+ if objective_terms_present is True:
410
+ return True
411
+ if isinstance(objective_offset, float) and objective_offset != 0.0:
412
+ return True
413
+ if objective_terms_present is False and objective_offset == 0.0:
414
+ return False
415
+ return None
416
+
417
+
418
+ def _read_metadata(
419
+ model: Any,
420
+ parameters: tuple[SolverParameter, ...],
421
+ ) -> ReportedMetadata:
422
+ """The solver and problem metadata reported by PySCIPOpt."""
423
+ api_version: str | None = _read_value(
424
+ lambda: str(
425
+ pyscipopt.__version__ # pyright: ignore[reportPrivateImportUsage] -- Exposed at runtime.
426
+ ),
427
+ None,
428
+ )
429
+ major_version: int | None = _read_value(lambda: int(model.getMajorVersion()), None)
430
+ minor_version: int | None = _read_value(lambda: int(model.getMinorVersion()), None)
431
+ technical_version: int | None = _read_value(
432
+ lambda: int(model.getTechVersion()), None
433
+ )
434
+ solver_version: str | None = None
435
+
436
+ if (
437
+ major_version is not None
438
+ and minor_version is not None
439
+ and technical_version is not None
440
+ ):
441
+ solver_version = f"{major_version}.{minor_version}.{technical_version}"
442
+
443
+ problem: dict[str, int] = {}
444
+ variable_count: int | None = _read_value(
445
+ lambda: int(model.getNVars(transformed=False)), None
446
+ )
447
+ constraint_count: int | None = _read_value(
448
+ lambda: int(model.getNConss(transformed=False)), None
449
+ )
450
+
451
+ if variable_count is not None:
452
+ problem["num_variables"] = variable_count
453
+ if constraint_count is not None:
454
+ problem["num_constraints"] = constraint_count
455
+
456
+ variables: list[Any] | MissingReason = _read_value(
457
+ lambda: model.getVars(transformed=False),
458
+ MissingReason.FAILED,
459
+ )
460
+ if not isinstance(variables, MissingReason):
461
+ integer_variable_count: int = 0
462
+ integer_variable_count_available: bool = True
463
+ for variable in variables:
464
+ variable_type: str | MissingReason = _read_value(
465
+ lambda variable=variable: str(variable.vtype()).upper(),
466
+ MissingReason.FAILED,
467
+ )
468
+ if isinstance(variable_type, MissingReason):
469
+ integer_variable_count_available = False
470
+ break
471
+ if variable_type in {"BINARY", "INTEGER", "IMPLINT"}:
472
+ integer_variable_count += 1
473
+ if integer_variable_count_available:
474
+ problem["num_integer_variables"] = integer_variable_count
475
+
476
+ return ReportedMetadata(
477
+ solver="scip",
478
+ api_version=api_version,
479
+ solver_version=solver_version,
480
+ problem=problem,
481
+ parameters=parameters,
482
+ wall_time_includes_problem_build=False,
483
+ )
484
+
485
+
486
+ def _read_default_parameters(
487
+ model_class: type[Any],
488
+ ) -> dict[str, bool | int | float | str] | None:
489
+ """The PySCIPOpt parameter values of a new model."""
490
+ defaults_model: Any | MissingReason = _read_value(
491
+ lambda: model_class(),
492
+ MissingReason.FAILED,
493
+ )
494
+ if isinstance(defaults_model, MissingReason):
495
+ return None
496
+
497
+ default_values: dict[str, Any] | MissingReason
498
+ try:
499
+ default_values = _read_value(
500
+ lambda: defaults_model.getParams(),
501
+ MissingReason.FAILED,
502
+ )
503
+ finally:
504
+ _read_value(lambda: defaults_model.free(), None)
505
+
506
+ if isinstance(default_values, MissingReason) or not isinstance(
507
+ default_values, dict
508
+ ):
509
+ return None
510
+
511
+ default_parameters: dict[str, bool | int | float | str] = {}
512
+ for parameter_name, parameter_value in default_values.items():
513
+ if isinstance(parameter_name, str) and isinstance(
514
+ parameter_value,
515
+ (bool, int, float, str),
516
+ ):
517
+ default_parameters[parameter_name] = parameter_value
518
+ return default_parameters
519
+
520
+
521
+ def _read_parameters(
522
+ model: Any,
523
+ default_parameters: dict[str, bool | int | float | str] | None,
524
+ ) -> tuple[SolverParameter, ...]:
525
+ """The PySCIPOpt parameters that differ from their defaults."""
526
+ if default_parameters is None:
527
+ return ()
528
+
529
+ parameters: list[SolverParameter] = []
530
+ for parameter_name, default_value in default_parameters.items():
531
+ parameter_value: Any | MissingReason = _read_value(
532
+ lambda parameter_name=parameter_name: model.getParam(parameter_name),
533
+ MissingReason.FAILED,
534
+ )
535
+ if isinstance(parameter_value, MissingReason):
536
+ continue
537
+ if not isinstance(parameter_value, (bool, int, float, str)):
538
+ continue
539
+ if parameter_value == default_value:
540
+ continue
541
+ parameters.append(
542
+ SolverParameter(
543
+ name=parameter_name,
544
+ value=parameter_value,
545
+ origin=ParameterOrigin.EXPLICIT,
546
+ )
547
+ )
548
+
549
+ return tuple(parameters)
550
+
551
+
552
+ def _read_solve_counters(model: Any) -> dict[str, float | MissingReason]:
553
+ """The current values of PySCIPOpt counters and timers."""
554
+ return {
555
+ "solver_time_seconds": _read_number(lambda: float(model.getSolvingTime())),
556
+ "node_count": _read_number(lambda: float(model.getNNodes())),
557
+ "solution_count": _read_number(lambda: float(model.getNSols())),
558
+ "solutions_found": _read_number(lambda: float(model.getNSolsFound())),
559
+ "lp_count": _read_number(lambda: float(model.getNLPs())),
560
+ "lp_iterations": _read_number(lambda: float(model.getNLPIterations())),
561
+ "total_node_count": _read_number(lambda: float(model.getNTotalNodes())),
562
+ "presolving_time_seconds": _read_number(
563
+ lambda: float(model.getPresolvingTime())
564
+ ),
565
+ }
566
+
567
+
568
+ def _unavailable_solve_counters() -> dict[str, float | MissingReason]:
569
+ """Unavailable PySCIPOpt counters for a per-solve baseline."""
570
+ return {
571
+ "solver_time_seconds": MissingReason.FAILED,
572
+ "node_count": MissingReason.FAILED,
573
+ "solution_count": MissingReason.FAILED,
574
+ "solutions_found": MissingReason.FAILED,
575
+ "lp_count": MissingReason.FAILED,
576
+ "lp_iterations": MissingReason.FAILED,
577
+ "total_node_count": MissingReason.FAILED,
578
+ "presolving_time_seconds": MissingReason.FAILED,
579
+ }
580
+
581
+
582
+ def _add_native_counter(
583
+ native: dict[str, float],
584
+ metric_name: str,
585
+ current_value: float | MissingReason,
586
+ counters_before_solve: dict[str, float | MissingReason] | None,
587
+ ) -> None:
588
+ """One available PySCIPOpt counter difference in native metrics."""
589
+ value: float | MissingReason = _counter_difference(
590
+ current_value,
591
+ counters_before_solve,
592
+ metric_name,
593
+ )
594
+ if isinstance(value, MissingReason):
595
+ return
596
+ native[metric_name] = value
597
+
598
+
599
+ def _read_number(
600
+ reader: Callable[[], float],
601
+ ) -> float | MissingReason:
602
+ """A guarded PySCIPOpt numeric value."""
603
+ return _read_value(reader, MissingReason.FAILED)
604
+
605
+
606
+ def _counter_difference(
607
+ current_value: float | MissingReason,
608
+ counters_before_solve: dict[str, float | MissingReason] | None,
609
+ counter_name: str,
610
+ ) -> float | MissingReason:
611
+ """The nonnegative difference between two PySCIPOpt counter values."""
612
+ if isinstance(current_value, MissingReason):
613
+ return current_value
614
+ if counters_before_solve is None:
615
+ if counter_name == "solution_count":
616
+ return MissingReason.UNSUPPORTED
617
+ return current_value
618
+ previous_value: float | MissingReason = counters_before_solve.get(
619
+ counter_name,
620
+ MissingReason.FAILED,
621
+ )
622
+ if isinstance(previous_value, MissingReason):
623
+ return previous_value
624
+ difference: float = current_value - previous_value
625
+ if difference < 0.0:
626
+ return MissingReason.FAILED
627
+ return difference
628
+
629
+
630
+ def _read_scip_number(
631
+ reader: Callable[[], float],
632
+ solver_infinity: float | MissingReason,
633
+ ) -> float | MissingReason:
634
+ """A PySCIPOpt numeric value with its infinity sentinel normalized."""
635
+ value: float | MissingReason = _read_number(reader)
636
+ if isinstance(value, MissingReason):
637
+ return value
638
+ if math.isinf(value):
639
+ return value
640
+ if isinstance(solver_infinity, MissingReason) or solver_infinity <= 0.0:
641
+ return MissingReason.FAILED
642
+ if value >= solver_infinity:
643
+ return math.inf
644
+ if value <= -solver_infinity:
645
+ return -math.inf
646
+ return value
647
+
648
+
649
+ def _read_value[Value](
650
+ reader: Callable[[], Value],
651
+ unavailable: Value,
652
+ ) -> Value:
653
+ """A PySCIPOpt value or its fallback after a supported read error."""
654
+ try:
655
+ return reader()
656
+ except Exception: # noqa: BLE001
657
+ return unavailable
@@ -0,0 +1,5 @@
1
+ """The OR-Tools Routing integration."""
2
+
3
+ from .adapter import RoutingAdapter
4
+
5
+ __all__ = ["RoutingAdapter"]