ommx-python-mip-adapter 3.0.0b3__tar.gz → 3.0.0b5__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.0b3 → ommx_python_mip_adapter-3.0.0b5}/PKG-INFO +2 -2
  2. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter/adapter.py +60 -20
  3. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter.egg-info/PKG-INFO +2 -2
  4. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter.egg-info/requires.txt +1 -1
  5. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/pyproject.toml +2 -2
  6. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/tests/test_adapter.py +41 -20
  7. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/tests/test_integration.py +35 -1
  8. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/README.md +0 -0
  9. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter/__init__.py +0 -0
  10. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter/exception.py +0 -0
  11. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter/python_mip_to_ommx.py +0 -0
  12. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter.egg-info/SOURCES.txt +0 -0
  13. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter.egg-info/dependency_links.txt +0 -0
  14. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/ommx_python_mip_adapter.egg-info/top_level.txt +0 -0
  15. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/setup.cfg +0 -0
  16. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/tests/test_constant_constraint.py +0 -0
  17. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/tests/test_model_to_instance.py +0 -0
  18. {ommx_python_mip_adapter-3.0.0b3 → ommx_python_mip_adapter-3.0.0b5}/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.0b3
3
+ Version: 3.0.0b5
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.0b3
18
+ Requires-Dist: ommx<4.0.0,>=3.0.0b5
19
19
  Requires-Dist: mip<2.0.0,>=1.17.0
20
20
  Requires-Dist: cffi<2.0.0,>=1.15.0
21
21
 
@@ -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
@@ -15,13 +16,13 @@ from ommx.adapter import (
15
16
  from ommx import (
16
17
  Constraint,
17
18
  DecisionVariable,
18
- DegreeBound,
19
19
  Equality,
20
20
  Function,
21
21
  Instance,
22
22
  InstanceClass,
23
23
  InstanceClassClause,
24
24
  Kind,
25
+ PolynomialRequirement,
25
26
  PreparationPolicy,
26
27
  Sense,
27
28
  Solution,
@@ -34,20 +35,22 @@ from .exception import OMMXPythonMIPAdapterError
34
35
 
35
36
  _tracer = trace.get_tracer("ommx.adapter.python_mip")
36
37
 
37
- _LINEAR_CONSTRAINT_DEGREE_BOUNDS = {
38
- Equality.EqualToZero: DegreeBound.at_most(1),
39
- Equality.LessThanOrEqualToZero: DegreeBound.at_most(1),
38
+ _LINEAR_CONSTRAINT_POLYNOMIAL_REQUIREMENTS = {
39
+ Equality.EqualToZero: PolynomialRequirement.at_most(1),
40
+ Equality.LessThanOrEqualToZero: PolynomialRequirement.at_most(1),
40
41
  }
41
42
 
42
43
 
43
44
  class OMMXPythonMIPAdapter(SolverAdapter):
44
- INPUT_CLASS: ClassVar[InstanceClass | None] = InstanceClass(
45
+ INPUT_CLASS: ClassVar[InstanceClass] = InstanceClass(
45
46
  [
46
47
  InstanceClassClause(
47
48
  label="python-mip-linear-mip",
48
49
  allowed_variable_kinds={Kind.Binary, Kind.Integer, Kind.Continuous},
49
- objective_degree_bound=DegreeBound.at_most(1),
50
- regular_constraint_degree_bounds=_LINEAR_CONSTRAINT_DEGREE_BOUNDS,
50
+ objective_polynomial_requirement=PolynomialRequirement.at_most(1),
51
+ regular_constraint_polynomial_requirements=(
52
+ _LINEAR_CONSTRAINT_POLYNOMIAL_REQUIREMENTS
53
+ ),
51
54
  allowed_senses={Sense.Minimize, Sense.Maximize},
52
55
  )
53
56
  ]
@@ -59,8 +62,9 @@ class OMMXPythonMIPAdapter(SolverAdapter):
59
62
 
60
63
  Python-MIP currently accepts only regular constraints through this
61
64
  adapter. The recommendation therefore lowers the special-constraint
62
- families currently understood by OMMX. The returned policy is fresh and
63
- may be edited by the caller before :meth:`Instance.prepare` is invoked.
65
+ families currently understood by OMMX. The easy API applies a fresh
66
+ policy to an isolated copy; callers may instead edit and apply one
67
+ before invoking :meth:`solve_without_preparation`.
64
68
  """
65
69
  return PreparationPolicy(
66
70
  special_constraints=SpecialConstraintPreparation.lower_special_constraints(
@@ -131,7 +135,10 @@ class OMMXPythonMIPAdapter(SolverAdapter):
131
135
  """
132
136
  Solve the given ommx.Instance using Python-MIP, returning an ommx.Solution.
133
137
 
134
- :param ommx_instance: The ommx.Instance to solve.
138
+ The input instance is not modified. An isolated copy is prepared with
139
+ the recommended Python-MIP policy before preparation-free execution.
140
+
141
+ :param ommx_instance: The ommx.Instance to prepare and solve.
135
142
  :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>`.
136
143
  :param verbose: If True, enable Python-MIP's verbose mode
137
144
 
@@ -236,9 +243,28 @@ class OMMXPythonMIPAdapter(SolverAdapter):
236
243
  1.0
237
244
 
238
245
  """
239
- _ = diagnostics
246
+ prepared = copy.copy(ommx_instance)
247
+ prepared.prepare(cls.INPUT_CLASS, cls.recommended_preparation_policy())
248
+ return cls.solve_without_preparation(
249
+ prepared,
250
+ relax=relax,
251
+ verbose=verbose,
252
+ diagnostics=diagnostics,
253
+ )
254
+
255
+ @classmethod
256
+ def solve_without_preparation(
257
+ cls,
258
+ ommx_instance: Instance,
259
+ relax: bool = False,
260
+ verbose: bool = False,
261
+ *,
262
+ diagnostics: DiagnosticsSink | None = None,
263
+ ) -> Solution:
264
+ """Solve an exact Python-MIP Adapter input without preparing it."""
240
265
  with _tracer.start_as_current_span("solve") as span:
241
266
  span.set_attribute("adapter", f"{cls.__module__}.{cls.__qualname__}")
267
+ _ = diagnostics
242
268
  adapter = cls(ommx_instance, relax=relax, verbose=verbose)
243
269
  model = adapter.solver_input
244
270
  with _tracer.start_as_current_span("call"):
@@ -270,6 +296,10 @@ class OMMXPythonMIPAdapter(SolverAdapter):
270
296
  `solver_input`, you must set `solution.relaxation` yourself if you
271
297
  care about this value.
272
298
 
299
+ Backend optimality is mapped through the instance's output-objective
300
+ semantics. It remains unspecified when active-formulation optimality
301
+ does not transport to that objective.
302
+
273
303
  Examples
274
304
  =========
275
305
 
@@ -303,17 +333,22 @@ class OMMXPythonMIPAdapter(SolverAdapter):
303
333
  state = self.decode_to_state(data)
304
334
  solution = self.instance.evaluate(state)
305
335
 
306
- dual_variables = {}
307
- for constraint in data.constrs:
308
- pi = constraint.pi
309
- if pi is not None:
310
- id = int(constraint.name)
311
- dual_variables[id] = pi
312
- for constraint_id, dual_value in dual_variables.items():
313
- solution.set_dual_variable(constraint_id, dual_value)
336
+ # Duals certify the active formulation and need an explicit mapping
337
+ # before they can be attached to projected output semantics.
338
+ if self.instance.output_objective is None:
339
+ dual_variables = {}
340
+ for constraint in data.constrs:
341
+ pi = constraint.pi
342
+ if pi is not None:
343
+ id = int(constraint.name)
344
+ dual_variables[id] = pi
345
+ for constraint_id, dual_value in dual_variables.items():
346
+ solution.set_dual_variable(constraint_id, dual_value)
314
347
 
315
348
  if data.status == mip.OptimizationStatus.OPTIMAL:
316
- solution.optimality = Solution.OPTIMAL
349
+ solution.optimality = self.instance.map_active_optimality(
350
+ Solution.OPTIMAL
351
+ )
317
352
 
318
353
  if self._relax:
319
354
  solution.relaxation = Solution.LP_RELAXED
@@ -414,6 +449,11 @@ class OMMXPythonMIPAdapter(SolverAdapter):
414
449
  Translate ommx.Function to `mip.LinExpr` or `float`.
415
450
  """
416
451
  degree = f.degree()
452
+ if degree is None:
453
+ raise OMMXPythonMIPAdapterError(
454
+ "Non-polynomial functions are not supported. "
455
+ "Only linear (degree 1) and constant (degree 0) functions are supported."
456
+ )
417
457
  constant = f.constant_term
418
458
  if degree > 1:
419
459
  raise OMMXPythonMIPAdapterError(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ommx_python_mip_adapter
3
- Version: 3.0.0b3
3
+ Version: 3.0.0b5
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.0b3
18
+ Requires-Dist: ommx<4.0.0,>=3.0.0b5
19
19
  Requires-Dist: mip<2.0.0,>=1.17.0
20
20
  Requires-Dist: cffi<2.0.0,>=1.15.0
21
21
 
@@ -1,3 +1,3 @@
1
- ommx<4.0.0,>=3.0.0b3
1
+ ommx<4.0.0,>=3.0.0b5
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.0b3"
7
+ version = "3.0.0b5"
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.0b3, < 4.0.0",
26
+ "ommx >= 3.0.0b5, < 4.0.0",
27
27
  "mip >= 1.17.0, < 2.0.0",
28
28
  "cffi >= 1.15.0, < 2.0.0",
29
29
  ]
@@ -2,13 +2,14 @@ import pytest
2
2
 
3
3
  from ommx import (
4
4
  DecisionVariable,
5
- DegreeBound,
6
5
  Equality,
6
+ Function,
7
7
  IndicatorConstraint,
8
8
  Instance,
9
9
  InstanceClassMismatch,
10
10
  Kind,
11
11
  OneHotConstraint,
12
+ PolynomialRequirement,
12
13
  Sense,
13
14
  Sos1Constraint,
14
15
  )
@@ -18,7 +19,6 @@ from ommx_python_mip_adapter import OMMXPythonMIPAdapter
18
19
 
19
20
  def test_declares_linear_mip_input_class() -> None:
20
21
  input_class = OMMXPythonMIPAdapter.INPUT_CLASS
21
- assert input_class is not None
22
22
  [clause] = input_class.clauses
23
23
 
24
24
  assert clause.label == "python-mip-linear-mip"
@@ -27,12 +27,12 @@ def test_declares_linear_mip_input_class() -> None:
27
27
  Kind.Integer,
28
28
  Kind.Continuous,
29
29
  }
30
- assert clause.objective_degree_bound == DegreeBound.at_most(1)
31
- assert clause.regular_constraint_degree_bounds == {
32
- Equality.EqualToZero: DegreeBound.at_most(1),
33
- Equality.LessThanOrEqualToZero: DegreeBound.at_most(1),
30
+ assert clause.objective_polynomial_requirement == PolynomialRequirement.at_most(1)
31
+ assert clause.regular_constraint_polynomial_requirements == {
32
+ Equality.EqualToZero: PolynomialRequirement.at_most(1),
33
+ Equality.LessThanOrEqualToZero: PolynomialRequirement.at_most(1),
34
34
  }
35
- assert clause.indicator_constraint_degree_bounds == {}
35
+ assert clause.indicator_body_polynomial_requirements == {}
36
36
  assert not clause.allows_one_hot
37
37
  assert not clause.allows_sos1
38
38
  assert clause.allowed_senses == {Sense.Minimize, Sense.Maximize}
@@ -52,10 +52,8 @@ def test_input_class_accepts_complete_linear_mip_boundary(sense: Sense) -> None:
52
52
 
53
53
  report = OMMXPythonMIPAdapter.check_applicability(instance)
54
54
 
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 == ()
55
+ assert report.is_member
56
+ assert report.matching_clauses == [(0, "python-mip-linear-mip")]
59
57
 
60
58
 
61
59
  def test_error_nonlinear_objective():
@@ -71,11 +69,27 @@ def test_error_nonlinear_objective():
71
69
  with pytest.raises(AdapterNotApplicableError) as e:
72
70
  OMMXPythonMIPAdapter(ommx_instance)
73
71
  assert isinstance(
74
- e.value.report.input_membership.clause_reports[0].mismatches[0],
72
+ e.value.report.clause_reports[0].mismatches[0],
75
73
  InstanceClassMismatch.ObjectiveDegreeExceedsBound,
76
74
  )
77
75
 
78
76
 
77
+ def test_error_non_polynomial_objective() -> None:
78
+ x = DecisionVariable.continuous(0)
79
+ instance = Instance.from_components(
80
+ decision_variables=[x],
81
+ objective=abs(Function(x)),
82
+ constraints={},
83
+ sense=Sense.Minimize,
84
+ )
85
+
86
+ with pytest.raises(AdapterNotApplicableError) as error:
87
+ OMMXPythonMIPAdapter(instance)
88
+
89
+ [mismatch] = error.value.report.clause_reports[0].mismatches
90
+ assert isinstance(mismatch, InstanceClassMismatch.ObjectiveFunctionNotPolynomial)
91
+
92
+
79
93
  def test_error_nonlinear_constraint():
80
94
  # Objective function: 0
81
95
  # Constraint: 2.3 * x * x = 0
@@ -90,7 +104,7 @@ def test_error_nonlinear_constraint():
90
104
  with pytest.raises(AdapterNotApplicableError) as e:
91
105
  OMMXPythonMIPAdapter(ommx_instance)
92
106
  assert isinstance(
93
- e.value.report.input_membership.clause_reports[0].mismatches[0],
107
+ e.value.report.clause_reports[0].mismatches[0],
94
108
  InstanceClassMismatch.RegularConstraintDegreeExceedsBound,
95
109
  )
96
110
 
@@ -118,7 +132,7 @@ def test_rejects_used_unsupported_variable_kinds(
118
132
  with pytest.raises(AdapterNotApplicableError) as e:
119
133
  OMMXPythonMIPAdapter(instance)
120
134
 
121
- mismatch = e.value.report.input_membership.clause_reports[0].mismatches[0]
135
+ mismatch = e.value.report.clause_reports[0].mismatches[0]
122
136
  assert isinstance(mismatch, InstanceClassMismatch.VariableKindNotAllowed)
123
137
  assert mismatch.kind == kind
124
138
  assert mismatch.variable_ids == {0}
@@ -137,14 +151,14 @@ def test_ignores_unused_unsupported_variable_kind() -> None:
137
151
  report = OMMXPythonMIPAdapter.check_applicability(instance)
138
152
  adapter = OMMXPythonMIPAdapter(instance)
139
153
 
140
- assert report.is_applicable
154
+ assert report.is_member
141
155
  assert adapter.instance is instance
142
156
  assert [variable.name for variable in adapter.solver_input.vars] == ["0"]
143
157
 
144
158
 
145
159
  def test_rejects_special_constraints_without_mutating_input() -> None:
146
160
  x = DecisionVariable.binary(0)
147
- y = DecisionVariable.continuous(1)
161
+ y = DecisionVariable.continuous(1, lower=0, upper=2)
148
162
  instance = Instance.from_components(
149
163
  decision_variables=[x, y],
150
164
  objective=x + y,
@@ -165,13 +179,21 @@ def test_rejects_special_constraints_without_mutating_input() -> None:
165
179
  with pytest.raises(AdapterNotApplicableError) as e:
166
180
  OMMXPythonMIPAdapter(instance)
167
181
 
168
- mismatches = e.value.report.input_membership.clause_reports[0].mismatches
182
+ mismatches = e.value.report.clause_reports[0].mismatches
169
183
  mismatch_types = {type(mismatch) for mismatch in mismatches}
170
184
  assert InstanceClassMismatch.IndicatorConstraintsNotAllowed in mismatch_types
171
185
  assert InstanceClassMismatch.OneHotConstraintsNotAllowed in mismatch_types
172
186
  assert InstanceClassMismatch.Sos1ConstraintsNotAllowed in mismatch_types
173
187
  assert instance.to_v2_bytes() == before
174
188
 
189
+ with pytest.raises(AdapterNotApplicableError):
190
+ OMMXPythonMIPAdapter.solve_without_preparation(instance)
191
+ assert instance.to_v2_bytes() == before
192
+
193
+ solution = OMMXPythonMIPAdapter.solve(instance)
194
+ assert solution.feasible
195
+ assert instance.to_v2_bytes() == before
196
+
175
197
 
176
198
  def test_recommended_preparation_reaches_the_python_mip_input_class() -> None:
177
199
  x = DecisionVariable.binary(0)
@@ -192,16 +214,15 @@ def test_recommended_preparation_reaches_the_python_mip_input_class() -> None:
192
214
  sos1_constraints={30: Sos1Constraint(variables=[y])},
193
215
  )
194
216
  input_class = OMMXPythonMIPAdapter.INPUT_CLASS
195
- assert input_class is not None
196
217
  assert not input_class.contains(instance)
197
218
 
198
219
  policy = OMMXPythonMIPAdapter.recommended_preparation_policy()
199
220
  assert policy.special_constraints is not None
200
- assert policy.sense is None
221
+ assert policy.objective is None
201
222
  assert policy.integer_slack is None
202
223
  assert policy.integer_encoding is None
203
224
  assert policy.fixed_penalty is None
204
225
 
205
226
  assert instance.prepare(input_class, policy) is None
206
227
  assert input_class.contains(instance)
207
- assert OMMXPythonMIPAdapter.check_applicability(instance).is_applicable
228
+ 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(