ommx-python-mip-adapter 3.0.0b2__tar.gz → 3.0.0b4__tar.gz

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 (18) hide show
  1. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/PKG-INFO +5 -5
  2. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/README.md +3 -3
  3. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter/adapter.py +67 -12
  4. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter.egg-info/PKG-INFO +5 -5
  5. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter.egg-info/requires.txt +1 -1
  6. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/pyproject.toml +2 -2
  7. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/tests/test_adapter.py +49 -11
  8. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/tests/test_integration.py +35 -1
  9. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter/__init__.py +0 -0
  10. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter/exception.py +0 -0
  11. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter/python_mip_to_ommx.py +0 -0
  12. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter.egg-info/SOURCES.txt +0 -0
  13. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter.egg-info/dependency_links.txt +0 -0
  14. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/ommx_python_mip_adapter.egg-info/top_level.txt +0 -0
  15. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/setup.cfg +0 -0
  16. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/tests/test_constant_constraint.py +0 -0
  17. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/tests/test_model_to_instance.py +0 -0
  18. {ommx_python_mip_adapter-3.0.0b2 → ommx_python_mip_adapter-3.0.0b4}/tests/test_tracing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ommx_python_mip_adapter
3
- Version: 3.0.0b2
3
+ Version: 3.0.0b4
4
4
  Summary: An adapter for the Python-MIP from/to OMMX.
5
5
  Author-email: "Jij Inc." <info@j-ij.com>
6
6
  Project-URL: Repository, https://github.com/Jij-Inc/ommx
@@ -15,7 +15,7 @@ Classifier: License :: OSI Approved :: Apache Software License
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Requires-Python: >=3.10
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: ommx<4.0.0,>=3.0.0b2
18
+ Requires-Dist: ommx<4.0.0,>=3.0.0b4
19
19
  Requires-Dist: mip<2.0.0,>=1.17.0
20
20
  Requires-Dist: cffi<2.0.0,>=1.15.0
21
21
 
@@ -90,9 +90,9 @@ import mip
90
90
  import ommx_python_mip_adapter as adapter
91
91
 
92
92
  model = mip.Model()
93
- x1=model.add_var(name="1", var_type=mip.INTEGER, lb=0, ub=5)
94
- x2=model.add_var(name="2", var_type=mip.CONTINUOUS, lb=0, ub=5)
95
- model.objective = - x1 - 2 * x2
93
+ x1 = model.add_var(name="1", var_type=mip.INTEGER, lb=0, ub=5)
94
+ x2 = model.add_var(name="2", var_type=mip.CONTINUOUS, lb=0, ub=5)
95
+ model.objective = -x1 - 2 * x2
96
96
  model.add_constr(x1 + x2 - 6 <= 0)
97
97
 
98
98
  ommx_instance = adapter.model_to_instance(model)
@@ -69,9 +69,9 @@ import mip
69
69
  import ommx_python_mip_adapter as adapter
70
70
 
71
71
  model = mip.Model()
72
- x1=model.add_var(name="1", var_type=mip.INTEGER, lb=0, ub=5)
73
- x2=model.add_var(name="2", var_type=mip.CONTINUOUS, lb=0, ub=5)
74
- model.objective = - x1 - 2 * x2
72
+ x1 = model.add_var(name="1", var_type=mip.INTEGER, lb=0, ub=5)
73
+ x2 = model.add_var(name="2", var_type=mip.CONTINUOUS, lb=0, ub=5)
74
+ model.objective = -x1 - 2 * x2
75
75
  model.add_constr(x1 + x2 - 6 <= 0)
76
76
 
77
77
  ommx_instance = adapter.model_to_instance(model)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import copy
3
4
  from typing import ClassVar, Optional
4
5
 
5
6
  import mip
@@ -22,8 +23,11 @@ from ommx import (
22
23
  InstanceClass,
23
24
  InstanceClassClause,
24
25
  Kind,
26
+ PreparationPolicy,
25
27
  Sense,
26
28
  Solution,
29
+ SpecialConstraintKind,
30
+ SpecialConstraintPreparation,
27
31
  State,
28
32
  )
29
33
 
@@ -38,7 +42,7 @@ _LINEAR_CONSTRAINT_DEGREE_BOUNDS = {
38
42
 
39
43
 
40
44
  class OMMXPythonMIPAdapter(SolverAdapter):
41
- INPUT_CLASS: ClassVar[InstanceClass | None] = InstanceClass(
45
+ INPUT_CLASS: ClassVar[InstanceClass] = InstanceClass(
42
46
  [
43
47
  InstanceClassClause(
44
48
  label="python-mip-linear-mip",
@@ -50,6 +54,26 @@ class OMMXPythonMIPAdapter(SolverAdapter):
50
54
  ]
51
55
  )
52
56
 
57
+ @classmethod
58
+ def recommended_preparation_policy(cls) -> PreparationPolicy:
59
+ """Recommend lowering special constraints before using Python-MIP.
60
+
61
+ Python-MIP currently accepts only regular constraints through this
62
+ adapter. The recommendation therefore lowers the special-constraint
63
+ families currently understood by OMMX. The easy API applies a fresh
64
+ policy to an isolated copy; callers may instead edit and apply one
65
+ before invoking :meth:`solve_without_preparation`.
66
+ """
67
+ return PreparationPolicy(
68
+ special_constraints=SpecialConstraintPreparation.lower_special_constraints(
69
+ kinds={
70
+ SpecialConstraintKind.Indicator,
71
+ SpecialConstraintKind.OneHot,
72
+ SpecialConstraintKind.Sos1,
73
+ }
74
+ )
75
+ )
76
+
53
77
  def __init__(
54
78
  self,
55
79
  ommx_instance: Instance,
@@ -109,7 +133,10 @@ class OMMXPythonMIPAdapter(SolverAdapter):
109
133
  """
110
134
  Solve the given ommx.Instance using Python-MIP, returning an ommx.Solution.
111
135
 
112
- :param ommx_instance: The ommx.Instance to solve.
136
+ The input instance is not modified. An isolated copy is prepared with
137
+ the recommended Python-MIP policy before preparation-free execution.
138
+
139
+ :param ommx_instance: The ommx.Instance to prepare and solve.
113
140
  :param relax: If True, relax all integer variables to continuous variables by using the `relax` parameter in Python-MIP's `Model.optimize() <https://docs.python-mip.com/en/latest/classes.html#mip.Model.optimize>`.
114
141
  :param verbose: If True, enable Python-MIP's verbose mode
115
142
 
@@ -214,9 +241,28 @@ class OMMXPythonMIPAdapter(SolverAdapter):
214
241
  1.0
215
242
 
216
243
  """
217
- _ = diagnostics
244
+ prepared = copy.copy(ommx_instance)
245
+ prepared.prepare(cls.INPUT_CLASS, cls.recommended_preparation_policy())
246
+ return cls.solve_without_preparation(
247
+ prepared,
248
+ relax=relax,
249
+ verbose=verbose,
250
+ diagnostics=diagnostics,
251
+ )
252
+
253
+ @classmethod
254
+ def solve_without_preparation(
255
+ cls,
256
+ ommx_instance: Instance,
257
+ relax: bool = False,
258
+ verbose: bool = False,
259
+ *,
260
+ diagnostics: DiagnosticsSink | None = None,
261
+ ) -> Solution:
262
+ """Solve an exact Python-MIP Adapter input without preparing it."""
218
263
  with _tracer.start_as_current_span("solve") as span:
219
264
  span.set_attribute("adapter", f"{cls.__module__}.{cls.__qualname__}")
265
+ _ = diagnostics
220
266
  adapter = cls(ommx_instance, relax=relax, verbose=verbose)
221
267
  model = adapter.solver_input
222
268
  with _tracer.start_as_current_span("call"):
@@ -248,6 +294,10 @@ class OMMXPythonMIPAdapter(SolverAdapter):
248
294
  `solver_input`, you must set `solution.relaxation` yourself if you
249
295
  care about this value.
250
296
 
297
+ Backend optimality is mapped through the instance's output-objective
298
+ semantics. It remains unspecified when active-formulation optimality
299
+ does not transport to that objective.
300
+
251
301
  Examples
252
302
  =========
253
303
 
@@ -281,17 +331,22 @@ class OMMXPythonMIPAdapter(SolverAdapter):
281
331
  state = self.decode_to_state(data)
282
332
  solution = self.instance.evaluate(state)
283
333
 
284
- dual_variables = {}
285
- for constraint in data.constrs:
286
- pi = constraint.pi
287
- if pi is not None:
288
- id = int(constraint.name)
289
- dual_variables[id] = pi
290
- for constraint_id, dual_value in dual_variables.items():
291
- solution.set_dual_variable(constraint_id, dual_value)
334
+ # Duals certify the active formulation and need an explicit mapping
335
+ # before they can be attached to projected output semantics.
336
+ if self.instance.output_objective is None:
337
+ dual_variables = {}
338
+ for constraint in data.constrs:
339
+ pi = constraint.pi
340
+ if pi is not None:
341
+ id = int(constraint.name)
342
+ dual_variables[id] = pi
343
+ for constraint_id, dual_value in dual_variables.items():
344
+ solution.set_dual_variable(constraint_id, dual_value)
292
345
 
293
346
  if data.status == mip.OptimizationStatus.OPTIMAL:
294
- solution.optimality = Solution.OPTIMAL
347
+ solution.optimality = self.instance.map_active_optimality(
348
+ Solution.OPTIMAL
349
+ )
295
350
 
296
351
  if self._relax:
297
352
  solution.relaxation = Solution.LP_RELAXED
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ommx_python_mip_adapter
3
- Version: 3.0.0b2
3
+ Version: 3.0.0b4
4
4
  Summary: An adapter for the Python-MIP from/to OMMX.
5
5
  Author-email: "Jij Inc." <info@j-ij.com>
6
6
  Project-URL: Repository, https://github.com/Jij-Inc/ommx
@@ -15,7 +15,7 @@ Classifier: License :: OSI Approved :: Apache Software License
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Requires-Python: >=3.10
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: ommx<4.0.0,>=3.0.0b2
18
+ Requires-Dist: ommx<4.0.0,>=3.0.0b4
19
19
  Requires-Dist: mip<2.0.0,>=1.17.0
20
20
  Requires-Dist: cffi<2.0.0,>=1.15.0
21
21
 
@@ -90,9 +90,9 @@ import mip
90
90
  import ommx_python_mip_adapter as adapter
91
91
 
92
92
  model = mip.Model()
93
- x1=model.add_var(name="1", var_type=mip.INTEGER, lb=0, ub=5)
94
- x2=model.add_var(name="2", var_type=mip.CONTINUOUS, lb=0, ub=5)
95
- model.objective = - x1 - 2 * x2
93
+ x1 = model.add_var(name="1", var_type=mip.INTEGER, lb=0, ub=5)
94
+ x2 = model.add_var(name="2", var_type=mip.CONTINUOUS, lb=0, ub=5)
95
+ model.objective = -x1 - 2 * x2
96
96
  model.add_constr(x1 + x2 - 6 <= 0)
97
97
 
98
98
  ommx_instance = adapter.model_to_instance(model)
@@ -1,3 +1,3 @@
1
- ommx<4.0.0,>=3.0.0b2
1
+ ommx<4.0.0,>=3.0.0b4
2
2
  mip<2.0.0,>=1.17.0
3
3
  cffi<2.0.0,>=1.15.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ommx_python_mip_adapter"
7
- version = "3.0.0b2"
7
+ version = "3.0.0b4"
8
8
 
9
9
  description = "An adapter for the Python-MIP from/to OMMX."
10
10
  authors = [{ name = "Jij Inc.", email = "info@j-ij.com" }]
@@ -23,7 +23,7 @@ classifiers = [
23
23
  ]
24
24
 
25
25
  dependencies = [
26
- "ommx >= 3.0.0b2, < 4.0.0",
26
+ "ommx >= 3.0.0b4, < 4.0.0",
27
27
  "mip >= 1.17.0, < 2.0.0",
28
28
  "cffi >= 1.15.0, < 2.0.0",
29
29
  ]
@@ -18,7 +18,6 @@ from ommx_python_mip_adapter import OMMXPythonMIPAdapter
18
18
 
19
19
  def test_declares_linear_mip_input_class() -> None:
20
20
  input_class = OMMXPythonMIPAdapter.INPUT_CLASS
21
- assert input_class is not None
22
21
  [clause] = input_class.clauses
23
22
 
24
23
  assert clause.label == "python-mip-linear-mip"
@@ -52,10 +51,8 @@ def test_input_class_accepts_complete_linear_mip_boundary(sense: Sense) -> None:
52
51
 
53
52
  report = OMMXPythonMIPAdapter.check_applicability(instance)
54
53
 
55
- assert report.is_applicable
56
- assert report.input_membership.matching_clauses == [(0, "python-mip-linear-mip")]
57
- assert report.preconditions_checked
58
- assert report.precondition_violations == ()
54
+ assert report.is_member
55
+ assert report.matching_clauses == [(0, "python-mip-linear-mip")]
59
56
 
60
57
 
61
58
  def test_error_nonlinear_objective():
@@ -71,7 +68,7 @@ def test_error_nonlinear_objective():
71
68
  with pytest.raises(AdapterNotApplicableError) as e:
72
69
  OMMXPythonMIPAdapter(ommx_instance)
73
70
  assert isinstance(
74
- e.value.report.input_membership.clause_reports[0].mismatches[0],
71
+ e.value.report.clause_reports[0].mismatches[0],
75
72
  InstanceClassMismatch.ObjectiveDegreeExceedsBound,
76
73
  )
77
74
 
@@ -90,7 +87,7 @@ def test_error_nonlinear_constraint():
90
87
  with pytest.raises(AdapterNotApplicableError) as e:
91
88
  OMMXPythonMIPAdapter(ommx_instance)
92
89
  assert isinstance(
93
- e.value.report.input_membership.clause_reports[0].mismatches[0],
90
+ e.value.report.clause_reports[0].mismatches[0],
94
91
  InstanceClassMismatch.RegularConstraintDegreeExceedsBound,
95
92
  )
96
93
 
@@ -118,7 +115,7 @@ def test_rejects_used_unsupported_variable_kinds(
118
115
  with pytest.raises(AdapterNotApplicableError) as e:
119
116
  OMMXPythonMIPAdapter(instance)
120
117
 
121
- mismatch = e.value.report.input_membership.clause_reports[0].mismatches[0]
118
+ mismatch = e.value.report.clause_reports[0].mismatches[0]
122
119
  assert isinstance(mismatch, InstanceClassMismatch.VariableKindNotAllowed)
123
120
  assert mismatch.kind == kind
124
121
  assert mismatch.variable_ids == {0}
@@ -137,14 +134,14 @@ def test_ignores_unused_unsupported_variable_kind() -> None:
137
134
  report = OMMXPythonMIPAdapter.check_applicability(instance)
138
135
  adapter = OMMXPythonMIPAdapter(instance)
139
136
 
140
- assert report.is_applicable
137
+ assert report.is_member
141
138
  assert adapter.instance is instance
142
139
  assert [variable.name for variable in adapter.solver_input.vars] == ["0"]
143
140
 
144
141
 
145
142
  def test_rejects_special_constraints_without_mutating_input() -> None:
146
143
  x = DecisionVariable.binary(0)
147
- y = DecisionVariable.continuous(1)
144
+ y = DecisionVariable.continuous(1, lower=0, upper=2)
148
145
  instance = Instance.from_components(
149
146
  decision_variables=[x, y],
150
147
  objective=x + y,
@@ -165,9 +162,50 @@ def test_rejects_special_constraints_without_mutating_input() -> None:
165
162
  with pytest.raises(AdapterNotApplicableError) as e:
166
163
  OMMXPythonMIPAdapter(instance)
167
164
 
168
- mismatches = e.value.report.input_membership.clause_reports[0].mismatches
165
+ mismatches = e.value.report.clause_reports[0].mismatches
169
166
  mismatch_types = {type(mismatch) for mismatch in mismatches}
170
167
  assert InstanceClassMismatch.IndicatorConstraintsNotAllowed in mismatch_types
171
168
  assert InstanceClassMismatch.OneHotConstraintsNotAllowed in mismatch_types
172
169
  assert InstanceClassMismatch.Sos1ConstraintsNotAllowed in mismatch_types
173
170
  assert instance.to_v2_bytes() == before
171
+
172
+ with pytest.raises(AdapterNotApplicableError):
173
+ OMMXPythonMIPAdapter.solve_without_preparation(instance)
174
+ assert instance.to_v2_bytes() == before
175
+
176
+ solution = OMMXPythonMIPAdapter.solve(instance)
177
+ assert solution.feasible
178
+ assert instance.to_v2_bytes() == before
179
+
180
+
181
+ def test_recommended_preparation_reaches_the_python_mip_input_class() -> None:
182
+ x = DecisionVariable.binary(0)
183
+ y = DecisionVariable.continuous(1, lower=0, upper=2)
184
+ instance = Instance.from_components(
185
+ decision_variables=[x, y],
186
+ objective=x + y,
187
+ constraints={},
188
+ sense=Sense.Minimize,
189
+ indicator_constraints={
190
+ 10: IndicatorConstraint(
191
+ indicator_variable=x,
192
+ function=y - 1,
193
+ equality=Equality.LessThanOrEqualToZero,
194
+ )
195
+ },
196
+ one_hot_constraints={20: OneHotConstraint(variables=[x])},
197
+ sos1_constraints={30: Sos1Constraint(variables=[y])},
198
+ )
199
+ input_class = OMMXPythonMIPAdapter.INPUT_CLASS
200
+ assert not input_class.contains(instance)
201
+
202
+ policy = OMMXPythonMIPAdapter.recommended_preparation_policy()
203
+ assert policy.special_constraints is not None
204
+ assert policy.objective is None
205
+ assert policy.integer_slack is None
206
+ assert policy.integer_encoding is None
207
+ assert policy.fixed_penalty is None
208
+
209
+ assert instance.prepare(input_class, policy) is None
210
+ assert input_class.contains(instance)
211
+ assert OMMXPythonMIPAdapter.check_applicability(instance).is_member
@@ -1,6 +1,6 @@
1
1
  import pytest
2
2
 
3
- from ommx import Instance, DecisionVariable, Solution
3
+ from ommx import DecisionVariable, Instance, Optimality, Sense, Solution
4
4
  from ommx.adapter import InfeasibleDetected, UnboundedDetected, NoSolutionReturned
5
5
  from ommx.testing import SingleFeasibleLPGenerator, DataType
6
6
 
@@ -75,6 +75,40 @@ def test_solution_optimality():
75
75
  assert solution.optimality == Solution.OPTIMAL
76
76
 
77
77
 
78
+ def test_solution_optimality_is_not_transported_through_fixed_penalty():
79
+ x = DecisionVariable.binary(0)
80
+ instance = Instance.from_components(
81
+ decision_variables=[x],
82
+ objective=x,
83
+ constraints={7: x == 1},
84
+ sense=Instance.MINIMIZE,
85
+ )
86
+ instance.to_qubo(uniform_penalty_weight=0.0)
87
+
88
+ solution = OMMXPythonMIPAdapter.solve(instance)
89
+
90
+ assert not solution.feasible
91
+ assert solution.optimality == Optimality.Unspecified
92
+
93
+
94
+ def test_output_objective_omits_active_formulation_dual_values():
95
+ x = DecisionVariable.continuous(0, lower=0)
96
+ instance = Instance.from_components(
97
+ decision_variables=[x],
98
+ objective=x,
99
+ constraints={0: x <= 1},
100
+ sense=Sense.Maximize,
101
+ )
102
+ assert instance.convert_active_objective(Sense.Minimize)
103
+
104
+ adapter = OMMXPythonMIPAdapter(instance)
105
+ model = adapter.solver_input
106
+ model.optimize()
107
+ solution = adapter.decode(model)
108
+
109
+ assert solution.get_dual_variable(0) is None
110
+
111
+
78
112
  def test_partial_evaluate():
79
113
  x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)]
80
114
  instance = Instance.from_components(