diff --git a/docs/api/_items/ommx.BinaryPowerPreparation.rst b/docs/api/_items/ommx.BinaryPowerPreparation.rst new file mode 100644 index 000000000..27424e7ba --- /dev/null +++ b/docs/api/_items/ommx.BinaryPowerPreparation.rst @@ -0,0 +1,4 @@ +BinaryPowerPreparation +====================== + +.. pyo3-api-class:: ommx BinaryPowerPreparation diff --git a/docs/api/_items/ommx.ObjectivePreparation.rst b/docs/api/_items/ommx.ObjectivePreparation.rst new file mode 100644 index 000000000..ea060edf9 --- /dev/null +++ b/docs/api/_items/ommx.ObjectivePreparation.rst @@ -0,0 +1,4 @@ +ObjectivePreparation +==================== + +.. pyo3-api-class:: ommx ObjectivePreparation diff --git a/docs/api/_items/ommx.SensePreparation.rst b/docs/api/_items/ommx.SensePreparation.rst deleted file mode 100644 index 877bc92a2..000000000 --- a/docs/api/_items/ommx.SensePreparation.rst +++ /dev/null @@ -1,4 +0,0 @@ -SensePreparation -================ - -.. pyo3-api-class:: ommx SensePreparation diff --git a/docs/api/api_reference.json b/docs/api/api_reference.json index 6fc366474..0ef8a3a29 100644 --- a/docs/api/api_reference.json +++ b/docs/api/api_reference.json @@ -2970,6 +2970,58 @@ ], "deprecated": null }, + { + "kind": "Class", + "name": "BinaryPowerPreparation", + "doc": "Reduce powers of active Binary variables during Preparation.", + "bases": [], + "methods": [ + { + "name": "__eq__", + "doc": "", + "signatures": [ + { + "parameters": [ + { + "name": "other", + "type_": { + "display": "object", + "link_target": null, + "children": [] + }, + "default": null + } + ], + "return_type": { + "display": "bool", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "__new__", + "doc": "", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "BinaryPowerPreparation", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + } + ], + "attributes": [], + "deprecated": null + }, { "kind": "Class", "name": "Bound", @@ -3979,7 +4031,7 @@ { "kind": "Class", "name": "DecisionVariable", - "doc": "Decision variable in an optimization problem.\n\nThis class represents a variable that will be optimized in a mathematical programming problem.\nIt supports various types (binary, integer, continuous, semi-integer, semi-continuous) and\ncan be used in arithmetic expressions to build objective functions and constraints.\nConstruction raises ValueError when the kind discriminator is unknown or\nthe requested bound cannot be normalized for the selected variable kind.\n\nNote that this object overloads `==` for creating a constraint, not for equality comparison.\n\n# Examples\n\n```python\n>>> x = DecisionVariable.integer(1)\n>>> x == 1 # Returns Constraint, not bool\nConstraint(...)\n```\n\nFor object equality comparison, use the ``equals_to()`` method or compare IDs:\n\n```python\n>>> y = DecisionVariable.integer(2)\n>>> x.id == y.id\nFalse\n```", + "doc": "Decision variable in an optimization problem.\n\nThis class represents a variable that will be optimized in a mathematical programming problem.\nIt supports various types (binary, integer, continuous, semi-integer, semi-continuous) and\ncan be used in arithmetic expressions to build objective functions and constraints.\nConstruction raises ValueError when the kind discriminator is unknown or\nthe requested bound cannot be normalized for the selected variable kind.\n\nNote that this object overloads `==` for creating a constraint, not for equality comparison.\n\n# Examples\n\n>>> x = DecisionVariable.integer(1)\n>>> x == 1 # Returns Constraint, not bool\nConstraint(...)\n\nFor object equality comparison, use the ``equals_to()`` method or compare IDs:\n\n>>> y = DecisionVariable.integer(2)\n>>> x.id == y.id\nFalse", "bases": [], "methods": [ { @@ -7270,7 +7322,7 @@ }, { "name": "evaluate_bound", - "doc": "Compute an interval bound of this function given variable bounds.\n\nMissing IDs in `bounds` are treated as unbounded (`Bound.unbounded()`).\n\n**Args:**\n\n- `bounds`: Mapping from variable ID to its {class}`~ommx.Bound`.\n\n**Returns:** A {class}`~ommx.Bound` that contains $[\\inf f, \\sup f]$ over the given variable bounds.\n\n**Tightness:** This evaluates the bound **term by term** (monomial-wise)\nand sums the per-term intervals. The result is a **sound\nover-approximation** of the true range $[\\inf f, \\sup f]$ but is **not\nguaranteed to be tight**, because it ignores dependencies between terms\nthat share variables. For example, $f = x^2 - x$ with $x \\in [0, 1]$\nhas true range $[-1/4, 0]$ (minimum at $x = 1/2$), but term-wise\nevaluation yields $[0, 1] + (-[0, 1]) = [-1, 1]$.\n\n# Examples\n\n```python\n>>> from ommx import Function, Linear, Bound\n>>> f = Function(Linear(terms={1: 2}, constant=3)) # 2*x1 + 3\n>>> b = f.evaluate_bound({1: Bound(0.0, 2.0)})\n>>> (b.lower, b.upper)\n(3.0, 7.0)\n```", + "doc": "Compute an interval bound of this function given variable bounds.\n\nMissing IDs in `bounds` are treated as unbounded (`Bound.unbounded()`).\n\n**Args:**\n\n- `bounds`: Mapping from variable ID to its {class}`~ommx.Bound`.\n\n**Returns:** A {class}`~ommx.Bound` that contains $[\\inf f, \\sup f]$ over the given variable bounds.\n\n**Tightness:** This evaluates the bound **term by term** (monomial-wise)\nand sums the per-term intervals. The result is a **sound\nover-approximation** of the true range $[\\inf f, \\sup f]$ but is **not\nguaranteed to be tight**, because it ignores dependencies between terms\nthat share variables. For example, $f = x^2 - x$ with $x \\in [0, 1]$\nhas true range $[-1/4, 0]$ (minimum at $x = 1/2$), but term-wise\nevaluation yields $[0, 1] + (-[0, 1]) = [-1, 1]$.\n\n# Examples\n\n>>> from ommx import Function, Linear, Bound\n>>> f = Function(Linear(terms={1: 2}, constant=3)) # 2*x1 + 3\n>>> b = f.evaluate_bound({1: Bound(0.0, 2.0)})\n>>> (b.lower, b.upper)\n(3.0, 7.0)", "signatures": [ { "parameters": [ @@ -8287,7 +8339,7 @@ { "kind": "Class", "name": "Instance", - "doc": "Optimization problem instance.\n\nThis class also contains annotations like {attr}`~ommx.Instance.title`.\nOMMX-defined annotations are stored in explicit protobuf fields, while\nuser-defined annotations are stored in the protobuf annotation map and\nmirrored to OMMX Artifact descriptors.\n\n# Examples\n\nCreate an instance for KnapSack Problem\n\n```python\n>>> from ommx import Instance, DecisionVariable\n```\n\nProfit and weight of items\n\n```python\n>>> p = [10, 13, 18, 31, 7, 15]\n>>> w = [11, 15, 20, 35, 10, 33]\n```\n\nDecision variables\n\n```python\n>>> x = [DecisionVariable.binary(i) for i in range(6)]\n```\n\nObjective and constraint\n\n```python\n>>> objective = sum(p[i] * x[i] for i in range(6))\n>>> constraint = sum(w[i] * x[i] for i in range(6)) <= 47\n```\n\nCompose as an instance\n\n```python\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=objective,\n... constraints=[constraint],\n... sense=Instance.MAXIMIZE,\n... )\n```", + "doc": "Optimization problem instance.\n\n# Invariants\n\nOutput-only variables are excluded from solver input and evaluated after the full state is populated.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x],\n... objective=3 * x,\n... constraints={},\n... sense=Sense.Maximize,\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> fixed = instance.partial_evaluate({0: 1})\n>>> assert fixed.sense == Sense.Minimize\n>>> assert fixed.objective.evaluate({}) == -3.0\n>>> assert fixed.required_ids() == set()\n>>> assert fixed.used_decision_variables == []\n>>> assert fixed.populate_state({}).entries == {0: 1.0}\n>>> solution = fixed.evaluate({})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0)", "bases": [], "methods": [ { @@ -8533,7 +8585,7 @@ }, { "name": "add_integer_slack_to_inequality", - "doc": "Convert inequality $f(x) \\leq 0$ to **inequality** $f(x) + b s \\leq 0$ with an integer slack variable $s$.\n\n- This should be used when {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack` is not applicable.\n\n- The bound of $s$ will be $[0, \\text{slack\\_upper\\_bound}]$, and the coefficient $b$ is determined from the lower bound of $f(x)$.\n\n- Since the slack variable is integer, the yielded inequality has residual error $\\min_s f(x) + b s$ at most $b$.\n And thus $b$ is returned to use scaling the penalty weight or other things.\n\n - Larger slack_upper_bound (i.e. finer-grained slack) yields smaller $b$, and thus smaller the residual error,\n but it needs more bits for the slack variable, and thus the problem size becomes larger.\n\n**Returns:**\nThe coefficient $b$ of the slack variable. If the constraint is trivially satisfied, this returns ``None``.\n\n# Examples\n\nLet's consider a simple inequality constraint x0 + 2*x1 <= 4.\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [\n... DecisionVariable.integer(i, lower=0, upper=3, name=\"x\", subscripts=[i])\n... for i in range(3)\n... ]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[\n... (x[0] + 2*x[1] <= 4).set_id(0)\n... ],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.constraints[0]\nConstraint(x0 + 2*x1 - 4 <= 0)\n```\n\nIntroduce an integer slack variable s in [0, 2]\n\n```python\n>>> b = instance.add_integer_slack_to_inequality(\n... constraint_id=0,\n... slack_upper_bound=2\n... )\n>>> b, instance.constraints[0]\n(2.0, Constraint(x0 + 2*x1 + 2*x3 - 4 <= 0))\n```", + "doc": "Convert inequality $f(x) \\leq 0$ to **inequality** $f(x) + b s \\leq 0$ with an integer slack variable $s$.\n\n- This should be used when {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack` is not applicable.\n\n- The bound of $s$ will be $[0, \\text{slack\\_upper\\_bound}]$, and the coefficient $b$ is determined from the lower bound of $f(x)$.\n\n- Since the slack variable is integer, the yielded inequality has residual error $\\min_s f(x) + b s$ at most $b$.\n And thus $b$ is returned to use scaling the penalty weight or other things.\n\n - Larger slack_upper_bound (i.e. finer-grained slack) yields smaller $b$, and thus smaller the residual error,\n but it needs more bits for the slack variable, and thus the problem size becomes larger.\n\n**Returns:**\nThe coefficient $b$ of the slack variable. If the constraint is trivially satisfied, this returns ``None``.\n\n# Examples\n\nLet's consider a simple inequality constraint x0 + 2*x1 <= 4.\n\n>>> from ommx import DecisionVariable, Equality, Instance, Sense\n>>> x = [\n... DecisionVariable.integer(i, lower=0, upper=3, name=\"x\", subscripts=[i])\n... for i in range(3)\n... ]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={0: x[0] + 2*x[1] <= 4},\n... sense=Sense.Maximize,\n... )\n\nIntroduce an integer slack variable s in [0, 2]\n\n>>> b = instance.add_integer_slack_to_inequality(\n... constraint_id=0,\n... slack_upper_bound=2\n... )\n>>> assert b == 2.0\n>>> assert instance.constraints[0].function.terms == {\n... (0,): 1.0, (1,): 2.0, (3,): 2.0, (): -4.0\n... }\n>>> assert instance.constraints[0].equality == Equality.LessThanOrEqualToZero", "signatures": [ { "parameters": [ @@ -8722,7 +8774,7 @@ }, { "name": "as_hubo_format", - "doc": "", + "doc": "Return the active objective in HUBO format without preparing the instance.\n\n# Postconditions\n\nThe returned coefficients represent the active objective rather than preserved output semantics.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x + 5, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> hubo, offset = instance.as_hubo_format()\n>>> assert (hubo, offset) == ({(0,): -3.0}, -5.0)\n>>> assert instance.objective.evaluate({0: 1}) == -8.0\n>>> assert instance.evaluate({0: 1}).objective == 8.0", "signatures": [ { "parameters": [], @@ -8749,7 +8801,7 @@ }, { "name": "as_maximization_problem", - "doc": "Convert the instance to a maximization problem.\n\nIf the instance is already a maximization problem, this does nothing.\n\n**Returns:**\n``True`` if the instance is converted, ``False`` if already a maximization problem.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[sum(x) == 1],\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.sense == Instance.MINIMIZE\nTrue\n>>> instance.objective\nFunction(x0 + x1 + x2)\n```\n\nConvert to a maximization problem\n\n```python\n>>> instance.as_maximization_problem()\nTrue\n>>> instance.sense == Instance.MAXIMIZE\nTrue\n>>> instance.objective\nFunction(-x0 - x1 - x2)\n```\n\nIf the instance is already a maximization problem, this does nothing\n\n```python\n>>> instance.as_maximization_problem()\nFalse\n```", + "doc": "Convert the instance to a maximization problem.\n\nIf both the active objective and the output objective already use\nmaximization, this does nothing.\n\n**Returns:**\n``True`` if either objective is converted, ``False`` if both already\nuse maximization.\n\n# Postconditions\n\nConversion changes both active and output objective semantics and is idempotent at the target sense.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Minimize\n... )\n>>> assert instance.convert_active_objective(Sense.Maximize)\n>>> assert instance.evaluate({0: 1}).objective == 3.0\n>>> assert instance.as_maximization_problem()\n>>> solution = instance.evaluate({0: 1})\n>>> assert instance.objective.evaluate({0: 1}) == -3.0\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, -3.0)\n>>> assert not instance.as_maximization_problem()", "signatures": [ { "parameters": [], @@ -8765,7 +8817,7 @@ }, { "name": "as_minimization_problem", - "doc": "Convert the instance to a minimization problem.\n\nIf the instance is already a minimization problem, this does nothing.\n\n**Returns:**\n``True`` if the instance is converted, ``False`` if already a minimization problem.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[sum(x) == 1],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.sense == Instance.MAXIMIZE\nTrue\n>>> instance.objective\nFunction(x0 + x1 + x2)\n```\n\nConvert to a minimization problem\n\n```python\n>>> instance.as_minimization_problem()\nTrue\n>>> instance.sense == Instance.MINIMIZE\nTrue\n>>> instance.objective\nFunction(-x0 - x1 - x2)\n```\n\nIf the instance is already a minimization problem, this does nothing\n\n```python\n>>> instance.as_minimization_problem()\nFalse\n```", + "doc": "Convert the instance to a minimization problem.\n\nIf both the active objective and the output objective already use\nminimization, this does nothing.\n\n**Returns:**\n``True`` if either objective is converted, ``False`` if both already\nuse minimization.\n\n# Postconditions\n\nConversion changes both active and output objective semantics and is idempotent at the target sense.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> assert instance.evaluate({0: 1}).objective == 3.0\n>>> assert instance.as_minimization_problem()\n>>> solution = instance.evaluate({0: 1})\n>>> assert instance.objective.evaluate({0: 1}) == -3.0\n>>> assert (solution.sense, solution.objective) == (Sense.Minimize, -3.0)\n>>> assert not instance.as_minimization_problem()", "signatures": [ { "parameters": [], @@ -8781,7 +8833,7 @@ }, { "name": "as_parametric_instance", - "doc": "", + "doc": "Convert this instance into a parameter-free parametric instance.\n\n# Postconditions\n\nMaterializing the result without parameters preserves both active and output objective semantics.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> restored = instance.as_parametric_instance().with_parameters({})\n>>> assert restored.sense == Sense.Minimize\n>>> assert restored.objective.evaluate({0: 1}) == -1.0\n>>> solution = restored.evaluate({0: 1})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0)", "signatures": [ { "parameters": [], @@ -8797,7 +8849,7 @@ }, { "name": "as_qubo_format", - "doc": "", + "doc": "Return the active objective in QUBO format without preparing the instance.\n\n# Postconditions\n\nThe returned coefficients represent the active objective rather than preserved output semantics.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x + 5, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> qubo, offset = instance.as_qubo_format()\n>>> assert (qubo, offset) == ({(0, 0): -3.0}, -5.0)\n>>> assert instance.objective.evaluate({0: 1}) == -8.0\n>>> assert instance.evaluate({0: 1}).objective == 8.0", "signatures": [ { "parameters": [], @@ -9134,6 +9186,32 @@ "is_async": false, "deprecated": null }, + { + "name": "convert_active_objective", + "doc": "Convert only the active objective used by a solver-facing formulation.\n\nThis changes {attr}`~ommx.Instance.sense` and\n{attr}`~ommx.Instance.objective` to ``target`` while preserving the\nobjective semantics returned by {meth}`~ommx.Instance.evaluate` and\n{meth}`~ommx.Instance.evaluate_samples`. Use\n{meth}`~ommx.Instance.as_minimization_problem` or\n{meth}`~ommx.Instance.as_maximization_problem` when the output objective\nshould be converted as part of the mathematical problem itself.\n\n**Returns:**\n``True`` if the active objective is converted, ``False`` if it already\nhas ``target``.\n\n# Postconditions\n\nConversion negates only the active objective and preserves evaluation semantics in either direction.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> for source, target in ((Sense.Maximize, Sense.Minimize), (Sense.Minimize, Sense.Maximize)):\n... instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x, constraints={}, sense=source\n... )\n... before = instance.evaluate({0: 1})\n... assert instance.convert_active_objective(target)\n... after = instance.evaluate({0: 1})\n... assert instance.sense == target\n... assert instance.objective.evaluate({0: 1}) == -3.0\n... assert (after.sense, after.objective) == (before.sense, before.objective)\n... assert not instance.convert_active_objective(target)", + "signatures": [ + { + "parameters": [ + { + "name": "target", + "type_": { + "display": "Sense", + "link_target": null, + "children": [] + }, + "default": null + } + ], + "return_type": { + "display": "bool", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "convert_all_indicators_to_constraints", "doc": "Convert every active indicator constraint to regular constraints using Big-M.\n\nSee {meth}`~ommx.Instance.convert_indicator_to_constraint` for the\nconversion rule. Returns a dict mapping each original indicator ID to the\nlist of regular constraint IDs it produced.\n\nAtomic: every active indicator is validated up front, and only if every\none is convertible are the conversions applied. If any indicator fails\nvalidation (non-finite bound on a required side), no mutation happens and\nthe instance is left untouched.", @@ -9169,7 +9247,7 @@ }, { "name": "convert_all_one_hots_to_constraints", - "doc": "Convert every active one-hot constraint to a regular equality constraint.\n\nSee {meth}`~ommx.Instance.convert_one_hot_to_constraint` for the conversion rule.\nReturns the IDs of the newly created regular constraints.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable, OneHotConstraint\n>>> x = [DecisionVariable.binary(i) for i in range(4)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={},\n... one_hot_constraints={\n... 1: OneHotConstraint(variables=x[:2]),\n... 2: OneHotConstraint(variables=x[2:]),\n... },\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.convert_all_one_hots_to_constraints()\n[0, 1]\n>>> instance.one_hot_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + x1 - 1 == 0), 1: Constraint(x2 + x3 - 1 == 0)}\n```", + "doc": "Convert every active one-hot constraint to a regular equality constraint.\n\nSee {meth}`~ommx.Instance.convert_one_hot_to_constraint` for the conversion rule.\nReturns the IDs of the newly created regular constraints.\n\n# Examples\n\n>>> from ommx import Instance, DecisionVariable, OneHotConstraint\n>>> x = [DecisionVariable.binary(i) for i in range(4)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={},\n... one_hot_constraints={\n... 1: OneHotConstraint(variables=x[:2]),\n... 2: OneHotConstraint(variables=x[2:]),\n... },\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.convert_all_one_hots_to_constraints()\n[0, 1]\n>>> instance.one_hot_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + x1 - 1 == 0), 1: Constraint(x2 + x3 - 1 == 0)}", "signatures": [ { "parameters": [], @@ -9191,7 +9269,7 @@ }, { "name": "convert_all_sos1_to_constraints", - "doc": "Convert every active SOS1 constraint to regular constraints using Big-M.\n\nSee {meth}`~ommx.Instance.convert_sos1_to_constraints` for the conversion\nrule. Returns a dict mapping each original SOS1 ID to the list of regular\nconstraint IDs it produced.\n\nAtomic: every active SOS1 is validated up front, and only if every one is\nconvertible are the conversions applied. If any SOS1 fails validation\n(unsupported kind, non-finite bound, domain excludes 0, etc.), no mutation\nhappens and the instance is left untouched.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable, Sos1Constraint\n>>> x = [DecisionVariable.binary(i) for i in range(4)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={},\n... sos1_constraints={\n... 1: Sos1Constraint(variables=x[:2]),\n... 2: Sos1Constraint(variables=x[2:]),\n... },\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.convert_all_sos1_to_constraints()\n{1: [0], 2: [1]}\n>>> instance.sos1_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + x1 - 1 <= 0), 1: Constraint(x2 + x3 - 1 <= 0)}\n```", + "doc": "Convert every active SOS1 constraint to regular constraints using Big-M.\n\nSee {meth}`~ommx.Instance.convert_sos1_to_constraints` for the conversion\nrule. Returns a dict mapping each original SOS1 ID to the list of regular\nconstraint IDs it produced.\n\nAtomic: every active SOS1 is validated up front, and only if every one is\nconvertible are the conversions applied. If any SOS1 fails validation\n(unsupported kind, non-finite bound, domain excludes 0, etc.), no mutation\nhappens and the instance is left untouched.\n\n# Examples\n\n>>> from ommx import Instance, DecisionVariable, Sos1Constraint\n>>> x = [DecisionVariable.binary(i) for i in range(4)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={},\n... sos1_constraints={\n... 1: Sos1Constraint(variables=x[:2]),\n... 2: Sos1Constraint(variables=x[2:]),\n... },\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.convert_all_sos1_to_constraints()\n{1: [0], 2: [1]}\n>>> instance.sos1_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + x1 - 1 <= 0), 1: Constraint(x2 + x3 - 1 <= 0)}", "signatures": [ { "parameters": [], @@ -9224,7 +9302,7 @@ }, { "name": "convert_indicator_to_constraint", - "doc": "Convert an indicator constraint to regular constraints using the Big-M method.\n\nAn indicator constraint ``y = 1 → f(x) <= 0`` (or ``= 0``) is encoded with\nupper and lower Big-M sides computed from the interval bounds of $f(x)$:\n\n$$\nf(x) + u y - u \\leq 0, \\qquad -f(x) - l y + l \\leq 0,\n$$\n\nwhere $u \\geq \\sup f(x)$ and $l \\leq \\inf f(x)$ are the upper and lower\nbounds of $f$ over the decision variables' domains.\n\nSide emission:\n\n- For ``<=`` indicators, only the upper side is considered; it is emitted\n iff $u > 0$. If $u \\leq 0$ the constraint is already implied by the\n variable bounds and no Big-M is emitted.\n- For ``=`` indicators, both sides are considered independently: upper\n emitted iff $u > 0$, lower emitted iff $l < 0$.\n\nWhen an equality side is skipped, the remaining constraints still enforce\nthe implication correctly because the skipped inequality is already implied\nby the variable bounds: e.g. $u \\leq 0$ together with the emitted lower side\nforces $f(x) = 0$ at $y = 1$ when $u = 0$, or renders $y = 1$ infeasible\nwhen $u < 0$ (correctly reflecting that $f(x) = 0$ has no solution under the\ngiven bounds). When both $u = 0$ and $l = 0$, the bound says $f(x) \\equiv 0$\nso the equality is vacuously satisfied and nothing is emitted.\n\nReturns the list of newly created regular constraint IDs in insertion order\n(upper first, then lower). The list is empty when both sides are redundant.\n\nRaises if the bound needed for an emitted side is non-finite, or if $f(x)$\nreferences a semi-continuous / semi-integer variable (the split domain\n$\\{0\\} \\cup [l, u]$ is not uniformly implemented, so Big-M conversion could\nsilently drop the upper side when $0 \\notin [l, u]$). The instance is not\nmutated on error.\n\n# Examples\n\nConvert an inequality indicator where the upper side is active:\n\n```python\n>>> from ommx import (\n... Instance, DecisionVariable, IndicatorConstraint, Equality,\n... )\n>>> x = DecisionVariable.continuous(0, lower=0.0, upper=5.0)\n>>> y = DecisionVariable.binary(1)\n>>> ic = IndicatorConstraint(\n... indicator_variable=y,\n... function=x - 2,\n... equality=Equality.LessThanOrEqualToZero,\n... )\n>>> instance = Instance.from_components(\n... decision_variables=[x, y],\n... objective=x,\n... constraints={},\n... indicator_constraints={1: ic},\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.convert_indicator_to_constraint(1)\n[0]\n>>> instance.indicator_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + 3*x1 - 5 <= 0)}\n```", + "doc": "Convert an indicator constraint to regular constraints using the Big-M method.\n\nAn indicator constraint ``y = 1 → f(x) <= 0`` (or ``= 0``) is encoded with\nupper and lower Big-M sides computed from the interval bounds of $f(x)$:\n\n$$\nf(x) + u y - u \\leq 0, \\qquad -f(x) - l y + l \\leq 0,\n$$\n\nwhere $u \\geq \\sup f(x)$ and $l \\leq \\inf f(x)$ are the upper and lower\nbounds of $f$ over the decision variables' domains.\n\nSide emission:\n\n- For ``<=`` indicators, only the upper side is considered; it is emitted\n iff $u > 0$. If $u \\leq 0$ the constraint is already implied by the\n variable bounds and no Big-M is emitted.\n- For ``=`` indicators, both sides are considered independently: upper\n emitted iff $u > 0$, lower emitted iff $l < 0$.\n\nWhen an equality side is skipped, the remaining constraints still enforce\nthe implication correctly because the skipped inequality is already implied\nby the variable bounds: e.g. $u \\leq 0$ together with the emitted lower side\nforces $f(x) = 0$ at $y = 1$ when $u = 0$, or renders $y = 1$ infeasible\nwhen $u < 0$ (correctly reflecting that $f(x) = 0$ has no solution under the\ngiven bounds). When both $u = 0$ and $l = 0$, the bound says $f(x) \\equiv 0$\nso the equality is vacuously satisfied and nothing is emitted.\n\nReturns the list of newly created regular constraint IDs in insertion order\n(upper first, then lower). The list is empty when both sides are redundant.\n\nRaises if the bound needed for an emitted side is non-finite, or if $f(x)$\nreferences a semi-continuous / semi-integer variable (the split domain\n$\\{0\\} \\cup [l, u]$ is not uniformly implemented, so Big-M conversion could\nsilently drop the upper side when $0 \\notin [l, u]$). The instance is not\nmutated on error.\n\n# Examples\n\nConvert an inequality indicator where the upper side is active:\n\n>>> from ommx import (\n... Instance, DecisionVariable, IndicatorConstraint, Equality,\n... )\n>>> x = DecisionVariable.continuous(0, lower=0.0, upper=5.0)\n>>> y = DecisionVariable.binary(1)\n>>> ic = IndicatorConstraint(\n... indicator_variable=y,\n... function=x - 2,\n... equality=Equality.LessThanOrEqualToZero,\n... )\n>>> instance = Instance.from_components(\n... decision_variables=[x, y],\n... objective=x,\n... constraints={},\n... indicator_constraints={1: ic},\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.convert_indicator_to_constraint(1)\n[0]\n>>> instance.indicator_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + 3*x1 - 5 <= 0)}", "signatures": [ { "parameters": [ @@ -9256,7 +9334,7 @@ }, { "name": "convert_inequality_to_equality_with_integer_slack", - "doc": "Convert an inequality constraint $f(x) \\leq 0$ to an equality constraint $f(x) + s/a = 0$ with an integer slack variable $s$.\n\n- Since $a$ is determined as the minimal multiplier to make every coefficient of $a f(x)$ integer,\n $a$ itself and the range of $s$ becomes impractically large. ``max_integer_range`` limits the maximal\n range of $s$, and returns error if the range exceeds it.\n\n- Since this method evaluates the bound of $f(x)$, we may find that:\n\n - The bound $[l, u]$ is strictly positive, i.e. $l > 0$:\n this means the instance is infeasible because this constraint never be satisfied,\n and an error is raised.\n\n - The bound $[l, u]$ is always negative, i.e. $u \\leq 0$:\n this means this constraint is trivially satisfied,\n the constraint is moved to {attr}`~ommx.Instance.removed_constraints`,\n and this method returns without introducing slack variable or raising an error.\n\n# Examples\n\nLet's consider a simple inequality constraint x0 + 2*x1 <= 5.\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [\n... DecisionVariable.integer(i, lower=0, upper=3, name=\"x\", subscripts=[i])\n... for i in range(3)\n... ]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[\n... (x[0] + 2*x[1] <= 5).set_id(0)\n... ],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.constraints[0]\nConstraint(x0 + 2*x1 - 5 <= 0)\n```\n\nIntroduce an integer slack variable\n\n```python\n>>> instance.convert_inequality_to_equality_with_integer_slack(\n... constraint_id=0,\n... max_integer_range=32\n... )\n>>> instance.constraints[0]\nConstraint(x0 + 2*x1 + x3 - 5 == 0)\n```\n\nRaises {class}`~ommx.ExactIntegerSlackError` when exact conversion is\nunavailable because the coefficients cannot be normalized or the slack\nrange exceeds ``max_integer_range``. Raises\n{class}`~ommx.InfeasibleDetected` when the bounds prove the inequality\ninfeasible.", + "doc": "Convert an inequality constraint $f(x) \\leq 0$ to an equality constraint $f(x) + s/a = 0$ with an integer slack variable $s$.\n\n- Since $a$ is determined as the minimal multiplier to make every coefficient of $a f(x)$ integer,\n $a$ itself and the range of $s$ becomes impractically large. ``max_integer_range`` limits the maximal\n range of $s$, and returns error if the range exceeds it.\n\n- Since this method evaluates the bound of $f(x)$, we may find that:\n\n - The bound $[l, u]$ is strictly positive, i.e. $l > 0$:\n this means the instance is infeasible because this constraint never be satisfied,\n and an error is raised.\n\n - The bound $[l, u]$ is always negative, i.e. $u \\leq 0$:\n this means this constraint is trivially satisfied,\n the constraint is moved to {attr}`~ommx.Instance.removed_constraints`,\n and this method returns without introducing slack variable or raising an error.\n\n# Examples\n\nLet's consider a simple inequality constraint x0 + 2*x1 <= 5.\n\n>>> from ommx import DecisionVariable, Equality, Instance, Sense\n>>> x = [\n... DecisionVariable.integer(i, lower=0, upper=3, name=\"x\", subscripts=[i])\n... for i in range(3)\n... ]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={0: x[0] + 2*x[1] <= 5},\n... sense=Sense.Maximize,\n... )\n\nIntroduce an integer slack variable\n\n>>> instance.convert_inequality_to_equality_with_integer_slack(\n... constraint_id=0,\n... max_integer_range=32\n... )\n>>> assert instance.constraints[0].function.terms == {\n... (0,): 1.0, (1,): 2.0, (3,): 1.0, (): -5.0\n... }\n>>> assert instance.constraints[0].equality == Equality.EqualToZero\n\nRaises {class}`~ommx.ExactIntegerSlackError` when exact conversion is\nunavailable because the coefficients cannot be normalized or the slack\nrange exceeds ``max_integer_range``. Raises\n{class}`~ommx.InfeasibleDetected` when the bounds prove the inequality\ninfeasible.", "signatures": [ { "parameters": [ @@ -9291,7 +9369,7 @@ }, { "name": "convert_one_hot_to_constraint", - "doc": "Convert a one-hot constraint to a regular equality constraint.\n\nA one-hot constraint over ``{x_1, ..., x_n}`` is mathematically equivalent to the\nlinear equality ``x_1 + ... + x_n - 1 == 0``. This method inserts that equality\nas a new regular constraint and moves the one-hot constraint into\n{attr}`~ommx.Instance.removed_one_hot_constraints` with\n``reason=\"ommx.Instance.convert_one_hot_to_constraint\"`` and a\n``constraint_id`` parameter pointing to the new regular constraint.\n\nReturns the ID of the newly created regular constraint.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable, OneHotConstraint\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={},\n... one_hot_constraints={1: OneHotConstraint(variables=x)},\n... sense=Instance.MINIMIZE,\n... )\n>>> new_id = instance.convert_one_hot_to_constraint(1)\n>>> instance.one_hot_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + x1 + x2 - 1 == 0)}\n>>> instance.removed_one_hot_constraints\n{1: RemovedOneHotConstraint(OneHotConstraint(exactly one of {x0, x1, x2} = 1), reason=ommx.Instance.convert_one_hot_to_constraint, constraint_id=0)}\n```", + "doc": "Convert a one-hot constraint to a regular equality constraint.\n\nA one-hot constraint over ``{x_1, ..., x_n}`` is mathematically equivalent to the\nlinear equality ``x_1 + ... + x_n - 1 == 0``. This method inserts that equality\nas a new regular constraint and moves the one-hot constraint into\n{attr}`~ommx.Instance.removed_one_hot_constraints` with\n``reason=\"ommx.Instance.convert_one_hot_to_constraint\"`` and a\n``constraint_id`` parameter pointing to the new regular constraint.\n\nReturns the ID of the newly created regular constraint.\n\n# Examples\n\n>>> from ommx import Instance, DecisionVariable, OneHotConstraint\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={},\n... one_hot_constraints={1: OneHotConstraint(variables=x)},\n... sense=Instance.MINIMIZE,\n... )\n>>> new_id = instance.convert_one_hot_to_constraint(1)\n>>> instance.one_hot_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + x1 + x2 - 1 == 0)}\n>>> instance.removed_one_hot_constraints\n{1: RemovedOneHotConstraint(OneHotConstraint(exactly one of {x0, x1, x2} = 1), reason=ommx.Instance.convert_one_hot_to_constraint, constraint_id=0)}", "signatures": [ { "parameters": [ @@ -9317,7 +9395,7 @@ }, { "name": "convert_sos1_to_constraints", - "doc": "Convert a SOS1 constraint to regular constraints using the Big-M method.\n\nA SOS1 constraint over $\\{x_1, \\ldots, x_n\\}$ with each $x_i \\in [l_i, u_i]$\nasserts that at most one $x_i$ is non-zero. Per variable, a binary indicator\n$y_i$ is introduced with the Big-M pair\n\n$$\nx_i - u_i y_i \\leq 0, \\qquad l_i y_i - x_i \\leq 0\n$$\n\n(trivial sides $u_i = 0$ or $l_i = 0$ are skipped), together with the single\ncardinality constraint\n\n$$\n\\sum_i y_i - 1 \\leq 0.\n$$\n\nIf $x_i$ is already binary with bound $[0, 1]$, $x_i$ itself is reused as its\nindicator (no new variable, no Big-M pair).\n\nReturns the list of newly created regular constraint IDs in insertion order\n(Big-M upper/lower pairs per non-binary variable, followed by the cardinality\nsum).\n\nRaises if any $x_i$ has a non-binary bound that is not finite, if its domain\nexcludes $0$, or if its kind is semi-continuous / semi-integer (the split\ndomain $\\{0\\} \\cup [l, u]$ is not uniformly implemented across the codebase\nyet, so Big-M conversion of these kinds is not supported).\nThe instance is not mutated on error.\n\n# Examples\n\nAll-binary SOS1 reduces to ``sum(x_i) - 1 <= 0`` without extra variables:\n\n```python\n>>> from ommx import Instance, DecisionVariable, Sos1Constraint\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={},\n... sos1_constraints={1: Sos1Constraint(variables=x)},\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.convert_sos1_to_constraints(1)\n[0]\n>>> instance.sos1_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + x1 + x2 - 1 <= 0)}\n>>> instance.removed_sos1_constraints\n{1: RemovedSos1Constraint(Sos1Constraint(at most one of {x0, x1, x2} ≠ 0), reason=ommx.Instance.convert_sos1_to_constraints, constraint_ids=0)}\n```", + "doc": "Convert a SOS1 constraint to regular constraints using the Big-M method.\n\nA SOS1 constraint over $\\{x_1, \\ldots, x_n\\}$ with each $x_i \\in [l_i, u_i]$\nasserts that at most one $x_i$ is non-zero. Per variable, a binary indicator\n$y_i$ is introduced with the Big-M pair\n\n$$\nx_i - u_i y_i \\leq 0, \\qquad l_i y_i - x_i \\leq 0\n$$\n\n(trivial sides $u_i = 0$ or $l_i = 0$ are skipped), together with the single\ncardinality constraint\n\n$$\n\\sum_i y_i - 1 \\leq 0.\n$$\n\nIf $x_i$ is already binary with bound $[0, 1]$, $x_i$ itself is reused as its\nindicator (no new variable, no Big-M pair).\n\nReturns the list of newly created regular constraint IDs in insertion order\n(Big-M upper/lower pairs per non-binary variable, followed by the cardinality\nsum).\n\nRaises if any $x_i$ has a non-binary bound that is not finite, if its domain\nexcludes $0$, or if its kind is semi-continuous / semi-integer (the split\ndomain $\\{0\\} \\cup [l, u]$ is not uniformly implemented across the codebase\nyet, so Big-M conversion of these kinds is not supported).\nThe instance is not mutated on error.\n\n# Examples\n\nAll-binary SOS1 reduces to ``sum(x_i) - 1 <= 0`` without extra variables:\n\n>>> from ommx import Instance, DecisionVariable, Sos1Constraint\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={},\n... sos1_constraints={1: Sos1Constraint(variables=x)},\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.convert_sos1_to_constraints(1)\n[0]\n>>> instance.sos1_constraints\n{}\n>>> instance.constraints\n{0: Constraint(x0 + x1 + x2 - 1 <= 0)}\n>>> instance.removed_sos1_constraints\n{1: RemovedSos1Constraint(Sos1Constraint(at most one of {x0, x1, x2} ≠ 0), reason=ommx.Instance.convert_sos1_to_constraints, constraint_ids=0)}", "signatures": [ { "parameters": [ @@ -9538,7 +9616,7 @@ }, { "name": "empty", - "doc": "Create trivial empty instance of minimization with zero objective, no constraints, and no decision variables.\n\n# Examples\n\n```python\n>>> from ommx import Instance\n>>> instance = Instance.minimize()\n>>> instance.sense == Instance.MINIMIZE\nTrue\n```", + "doc": "Create trivial empty instance of minimization with zero objective, no constraints, and no decision variables.\n\n# Examples\n\n>>> from ommx import Instance\n>>> instance = Instance.minimize()\n>>> instance.sense == Instance.MINIMIZE\nTrue", "signatures": [ { "parameters": [], @@ -9557,7 +9635,7 @@ }, { "name": "evaluate", - "doc": "Evaluate the given {class}`~ommx.State` into a {class}`~ommx.Solution`.\n\nThis method evaluates the problem instance using the provided state (a map from decision variable IDs to their values),\nand returns a {class}`~ommx.Solution` object containing objective value, evaluated constraint values, and feasibility information.\n\n# Examples\n\nCreate a simple instance with three binary variables and evaluate a solution:\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[(x[0] + x[1] <= 1).set_id(0)],\n... sense=Instance.MAXIMIZE,\n... )\n```\n\nEvaluate it with a state x0 = 1, x1 = 0, x2 = 0, and show the objective and constraints:\n\n```python\n>>> solution = instance.evaluate({0: 1, 1: 0, 2: 0})\n>>> solution.objective\n1.0\n```\n\nIf the value is out of the range, the solution is infeasible:\n\n```python\n>>> solution = instance.evaluate({0: 1, 1: 0, 2: 2})\n>>> solution.feasible\nFalse\n```\n\nIf some of the decision variables are not set, this raises an error:\n\n```python\n>>> instance.evaluate({0: 1, 1: 0})\n```\nTraceback (most recent call last):\n ...\nValueError: state is missing required variable IDs: {VariableID(2)}", + "doc": "Evaluate the given {class}`~ommx.State` into a {class}`~ommx.Solution`.\n\n# Postconditions\n\nEvaluation populates the full state before applying preserved output objective semantics.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> fixed = instance.partial_evaluate({0: 1})\n>>> solution = fixed.evaluate({})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0)\n\n# Errors\n\nEvaluation raises ``ValueError`` when an active required ID is missing.\n\n>>> required = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize\n... )\n>>> try:\n... required.evaluate({})\n... except ValueError as error:\n... assert \"missing required variable IDs\" in str(error)\n... else:\n... raise AssertionError(\"evaluation accepted a missing active ID\")", "signatures": [ { "parameters": [ @@ -9606,7 +9684,7 @@ }, { "name": "evaluate_samples", - "doc": "", + "doc": "Evaluate samples into a sample set.\n\n# Postconditions\n\nEvery sample restores fixed variables before applying preserved output objective semantics.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> fixed = instance.partial_evaluate({0: 1})\n>>> sample_set = fixed.evaluate_samples({7: {}})\n>>> assert sample_set.sense == Sense.Maximize\n>>> assert sample_set.objectives[7] == 3.0\n>>> assert sample_set.get(7).state.entries == {0: 1.0}", "signatures": [ { "parameters": [ @@ -10261,7 +10339,7 @@ }, { "name": "log_encode", - "doc": "Log-encode the integer decision variables.\n\nLog encoding of an integer variable $x \\in [l, u]$ is to represent by $m$ bits $b_i \\in \\{0, 1\\}$ by:\n\n$$x = \\sum_{i=0}^{m-2} 2^i b_i + (u - l - 2^{m-1} + 1) b_{m-1} + l$$\n\nwhere $m = \\lceil \\log_2(u - l + 1) \\rceil$.\n\n**Args:**\n- `decision_variable_ids`: The IDs of the integer decision variables to log-encode.\n If not specified (or empty), all used integer variables are log-encoded.\n- `atol`: Optional absolute tolerance used when normalizing integer\n bounds before encoding. If None, uses the default tolerance.\n\nRaises {class}`~ommx.LogEncodingError` when an exact representation is\nunavailable for a requested variable. Allocation and expression-rewrite\nfailures retain their original exception types.\n\n# Examples\n\nLet's consider a simple integer programming problem with three integer variables x0, x1, and x2.\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [\n... DecisionVariable.integer(i, lower=0, upper=3, name=\"x\", subscripts=[i])\n... for i in range(3)\n... ]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.objective\nFunction(x0 + x1 + x2)\n```\n\nTo log-encode the integer variables x0 and x2 (except x1), call log_encode:\n\n```python\n>>> instance.log_encode({0, 2})\n```\n\nInteger variable in range $[0, 3]$ can be represented by two binary variables:\n\n$$x_0 = b_{0,0} + 2 b_{0,1}, \\quad x_2 = b_{2,0} + 2 b_{2,1}$$\n\nAnd these are substituted into the objective and constraint functions.\n\n```python\n>>> instance.objective\nFunction(x1 + x3 + 2*x4 + x5 + 2*x6)\n```", + "doc": "Log-encode the integer decision variables.\n\nLog encoding of an integer variable $x \\in [l, u]$ is to represent by $m$ bits $b_i \\in \\{0, 1\\}$ by:\n\n$$x = \\sum_{i=0}^{m-2} 2^i b_i + (u - l - 2^{m-1} + 1) b_{m-1} + l$$\n\nwhere $m = \\lceil \\log_2(u - l + 1) \\rceil$.\n\n**Args:**\n- `decision_variable_ids`: The IDs of the integer decision variables to log-encode.\n If not specified (or empty), all used integer variables are log-encoded.\n- `atol`: Optional absolute tolerance used when normalizing integer\n bounds before encoding. If None, uses the default tolerance.\n\nRaises {class}`~ommx.LogEncodingError` when an exact representation is\nunavailable for a requested variable. Allocation and expression-rewrite\nfailures retain their original exception types.\n\n# Postconditions\n\nEncoding rewrites the active objective while output evaluation restores the encoded integer value.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.integer(0, lower=0, upper=3)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> instance.log_encode({0})\n>>> encoded_ids = instance.required_ids()\n>>> assert len(encoded_ids) == 2\n>>> state = {variable_id: 1 for variable_id in encoded_ids}\n>>> assert instance.objective.evaluate(state) == -3.0\n>>> solution = instance.evaluate(state)\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0)", "signatures": [ { "parameters": [ @@ -10314,7 +10392,7 @@ }, { "name": "logical_memory_profile", - "doc": "Generate folded stack format for memory profiling of this instance.\n\nThis method generates a format compatible with flamegraph visualization tools\nlike ``flamegraph.pl`` and ``inferno``. Each line has the format:\n\"frame1;frame2;...;frameN bytes\"\n\nThe output shows the hierarchical memory structure of the instance, making it\neasy to identify which components are consuming the most memory.\n\nTo visualize with flamegraph:\n\n1. Save the output to a file: ``profile.txt``\n2. Generate SVG: ``flamegraph.pl profile.txt > memory.svg``\n3. Open memory.svg in a browser\n\n**Returns:**\nFolded stack format string that can be visualized with flamegraph tools\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=x[0] + x[1],\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> profile = instance.logical_memory_profile()\n>>> isinstance(profile, str)\nTrue\n```", + "doc": "Generate folded stack format for memory profiling of this instance.\n\nThis method generates a format compatible with flamegraph visualization tools\nlike ``flamegraph.pl`` and ``inferno``. Each line has the format:\n\"frame1;frame2;...;frameN bytes\"\n\nThe output shows the hierarchical memory structure of the instance, making it\neasy to identify which components are consuming the most memory.\n\nTo visualize with flamegraph:\n\n1. Save the output to a file: ``profile.txt``\n2. Generate SVG: ``flamegraph.pl profile.txt > memory.svg``\n3. Open memory.svg in a browser\n\n**Returns:**\nFolded stack format string that can be visualized with flamegraph tools\n\n# Examples\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=x[0] + x[1],\n... constraints={},\n... sense=Sense.Maximize,\n... )\n>>> profile = instance.logical_memory_profile()\n>>> isinstance(profile, str)\nTrue", "signatures": [ { "parameters": [], @@ -10366,6 +10444,32 @@ "is_async": false, "deprecated": null }, + { + "name": "map_active_optimality", + "doc": "Map an optimality status for the active solver-facing formulation to\nthe objective semantics returned by evaluation.\n\nWhen the instance records that active-formulation optimality does not\ntransport to its output objective, this returns\n{attr}`~ommx.Optimality.Unspecified`.\n\n# Postconditions\n\nOptimality is preserved for equivalent objective conversion and discarded after penalty preparation.\n\n>>> from ommx import DecisionVariable, Instance, Optimality, Sense\n>>> x = DecisionVariable.binary(0)\n>>> equivalent = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert equivalent.convert_active_objective(Sense.Minimize)\n>>> statuses = (Optimality.Unspecified, Optimality.Optimal, Optimality.NotOptimal)\n>>> for status in statuses:\n... assert equivalent.map_active_optimality(status) == status\n>>> penalized = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize\n... )\n>>> _ = penalized.to_qubo(uniform_penalty_weight=1.0)\n>>> for status in statuses:\n... assert penalized.map_active_optimality(status) == Optimality.Unspecified", + "signatures": [ + { + "parameters": [ + { + "name": "active", + "type_": { + "display": "Optimality", + "link_target": null, + "children": [] + }, + "default": null + } + ], + "return_type": { + "display": "Optimality", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "maximize", "doc": "Create an empty maximization instance with a zero objective.\n\nDecision variables and constraints can be added incrementally with\n{meth}`new_binary` and {meth}`add_constraint`.", @@ -10535,7 +10639,7 @@ }, { "name": "partial_evaluate", - "doc": "Creates a new instance with specific decision variables fixed to given values.\n\nThis method substitutes the specified decision variables with their provided values,\ncreating a new problem instance where these variables are fixed. This is useful for\nscenarios such as:\n\n- Creating simplified sub-problems with some variables fixed\n- Incrementally solving a problem by fixing some variables and optimizing the rest\n- Testing specific configurations of a problem\n\n**Args:**\n- `state`: Maps decision variable IDs to their fixed values.\n Can be a {class}`~ommx.State` object or a dictionary mapping variable IDs to values.\n- `atol`: Absolute tolerance for floating point comparisons. If None, uses the default tolerance.\n\n**Returns:**\nA new instance with the specified decision variables fixed to their given values.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = DecisionVariable.binary(1)\n>>> y = DecisionVariable.binary(2)\n>>> instance = Instance.from_components(\n... decision_variables=[x, y],\n... objective=x + y,\n... constraints=[x + y <= 1],\n... sense=Instance.MINIMIZE\n... )\n>>> new_instance = instance.partial_evaluate({1: 1})\n>>> new_instance.objective\nFunction(x2 + 1)\n```\n\nFixed values are owned by the instance and exposed through the\nattached decision-variable view:\n\n```python\n>>> x = new_instance.attached_decision_variable(1)\n>>> x.substituted_value\n1.0\n```", + "doc": "Creates a new instance with specific decision variables fixed to given values.\n\nThis method substitutes the specified decision variables with their provided values,\ncreating a new problem instance where these variables are fixed. This is useful for\nscenarios such as:\n\n- Creating simplified sub-problems with some variables fixed\n- Incrementally solving a problem by fixing some variables and optimizing the rest\n- Testing specific configurations of a problem\n\n**Args:**\n- `state`: Maps decision variable IDs to their fixed values.\n Can be a {class}`~ommx.State` object or a dictionary mapping variable IDs to values.\n- `atol`: Absolute tolerance for floating point comparisons. If None, uses the default tolerance.\n\n**Returns:**\nA new instance with the specified decision variables fixed to their given values.\n\n# Postconditions\n\nThe new instance rewrites only active expressions while retaining fixed values for output evaluation.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> fixed = instance.partial_evaluate({0: 1})\n>>> assert instance.required_ids() == {0}\n>>> assert fixed.required_ids() == set()\n>>> assert fixed.objective.evaluate({}) == -3.0\n>>> assert fixed.attached_decision_variable(0).substituted_value == 1.0\n>>> solution = fixed.evaluate({})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0)", "signatures": [ { "parameters": [ @@ -10584,7 +10688,7 @@ }, { "name": "penalty_method", - "doc": "Convert to a parametric unconstrained instance by penalty method.\n\nRoughly, this converts a constrained problem:\n\n$$\\min_x f(x) \\quad \\text{s.t.} \\quad g_i(x) = 0 \\; (\\forall i), \\quad h_j(x) \\leq 0 \\; (\\forall j)$$\n\nto an unconstrained problem with parameters:\n\n$$\\min_x f(x) + \\sum_i \\lambda_i g_i(x)^2 + \\sum_j \\rho_j h_j(x)^2$$\n\nwhere $\\lambda_i$ and $\\rho_j$ are the penalty weight parameters for each constraint.\nIf you want to use single weight parameter, use {meth}`~ommx.Instance.uniform_penalty_method` instead.\n\nThe removed constraints are stored in {attr}`~ommx.ParametricInstance.removed_constraints`.\n\n> Note: This method converts inequality constraints $h(x) \\leq 0$ to $|h(x)|^2$ not to $\\max(0, h(x))^2$.\n> This means the penalty is enforced even for $h(x) < 0$ cases, and $h(x) = 0$ is unfairly favored.\n> This feature is intended to use with {meth}`~ommx.Instance.add_integer_slack_to_inequality`.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable, Constraint\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[x[0] + x[1] == 1, x[1] + x[2] == 1],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.objective\nFunction(x0 + x1 + x2)\n>>> pi = instance.penalty_method()\n```\n\nThe constraint is put in removed_constraints\n\n```python\n>>> pi.constraints\n[]\n>>> len(pi.removed_constraints)\n2\n>>> pi.removed_constraints[0]\nRemovedConstraint(x0 + x1 - 1 == 0, reason=ommx.Instance.penalty_method, parameter_id=3)\n>>> pi.removed_constraints[1]\nRemovedConstraint(x1 + x2 - 1 == 0, reason=ommx.Instance.penalty_method, parameter_id=4)\n```", + "doc": "Convert to a parametric unconstrained instance by penalty method.\n\nRoughly, this converts a constrained problem:\n\n$$\\min_x f(x) \\quad \\text{s.t.} \\quad g_i(x) = 0 \\; (\\forall i), \\quad h_j(x) \\leq 0 \\; (\\forall j)$$\n\nto an unconstrained problem with parameters:\n\n$$\\min_x f(x) + \\sum_i \\lambda_i g_i(x)^2 + \\sum_j \\rho_j h_j(x)^2$$\n\nwhere $\\lambda_i$ and $\\rho_j$ are the penalty weight parameters for each constraint.\nIf you want to use single weight parameter, use {meth}`~ommx.Instance.uniform_penalty_method` instead.\n\nThe removed constraints are stored in {attr}`~ommx.ParametricInstance.removed_constraints`.\n\n> Note: This method converts inequality constraints $h(x) \\leq 0$ to $|h(x)|^2$ not to $\\max(0, h(x))^2$.\n> This means the penalty is enforced even for $h(x) < 0$ cases, and $h(x) = 0$ is unfairly favored.\n> This feature is intended to use with {meth}`~ommx.Instance.add_integer_slack_to_inequality`.\n\n# Postconditions\n\nMaterialization evaluates penalty energy actively while retaining the pre-penalty objective for output and invalidating optimality transport.\n\n>>> from ommx import DecisionVariable, Instance, Optimality, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize\n... )\n>>> parametric = instance.penalty_method()\n>>> parameters = {parameter.id: 2.0 for parameter in parametric.parameters}\n>>> prepared = parametric.with_parameters(parameters)\n>>> assert parametric.constraints == {}\n>>> assert 7 in parametric.removed_constraints\n>>> assert prepared.objective.evaluate({0: 0}) == 2.0\n>>> solution = prepared.evaluate({0: 0})\n>>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False)\n>>> assert prepared.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified", "signatures": [ { "parameters": [], @@ -10600,7 +10704,7 @@ }, { "name": "populate_state", - "doc": "Populate fixed, irrelevant, and dependent decision variables in a state.\n\nThe input state must contain all decision variables that are actually used\nby this instance's objective and active constraints. The returned\n{class}`~ommx.State` contains every decision variable in the instance.", + "doc": "Populate fixed, irrelevant, and dependent decision variables in a state.\n\nThe input state must contain all decision variables that are actually used\nby this instance's objective and active constraints. The returned\n{class}`~ommx.State` contains every decision variable in the instance.\n\n# Postconditions\n\nThe returned state restores fixed variables needed only by preserved output semantics.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> fixed = instance.partial_evaluate({0: 1})\n>>> assert fixed.populate_state({}).entries == {0: 1.0}\n>>> assert fixed.evaluate({}).objective == 3.0", "signatures": [ { "parameters": [ @@ -10654,7 +10758,7 @@ }, { "name": "prepare", - "doc": "Apply the caller's ``policy`` to this instance in place to reach\n``input_class`` membership.\n\nSelected phases are applied at most once in this order, stopping as soon\nas membership is reached:\n\n1. ``special_constraints``:\n {meth}`~ommx.Instance.lower_special_constraints`\n2. ``sense``: {meth}`~ommx.Instance.as_minimization_problem`\n3. ``integer_slack``:\n {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack`,\n followed by {meth}`~ommx.Instance.add_integer_slack_to_inequality` only\n when exact conversion is unavailable and ``slack_upper_bound`` is set\n4. ``integer_encoding``: {meth}`~ommx.Instance.log_encode`\n5. ``fixed_penalty``\n\nSuccess guarantees membership only, not Adapter applicability. This\noperation is not transactional, so an error may leave the instance changed.\n{class}`~ommx.PreparationTargetNotReachedError` exposes the final membership\nreport when the selections do not reach ``input_class``.", + "doc": "Apply the caller's ``policy`` to this instance in place to reach\n``input_class`` membership.\n\nSelected phases are applied at most once in this order, stopping as soon\nas membership is reached:\n\n1. ``special_constraints``:\n {meth}`~ommx.Instance.lower_special_constraints`\n2. ``objective``: {meth}`~ommx.Instance.convert_active_objective`\n3. ``integer_slack``:\n {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack`,\n followed by {meth}`~ommx.Instance.add_integer_slack_to_inequality` only\n when exact conversion is unavailable and ``slack_upper_bound`` is set\n4. ``fixed_penalty``\n5. ``integer_encoding``: {meth}`~ommx.Instance.log_encode`\n6. ``binary_power_reduction``:\n {meth}`~ommx.Instance.reduce_binary_power`\n\nSuccess guarantees membership only, not Adapter applicability. This\noperation is not transactional, so an error may leave the instance changed.\n{class}`~ommx.PreparationTargetNotReachedError` exposes the final membership\nreport when the selections do not reach ``input_class``.\n\n# Postconditions\n\nSuccessful Preparation mutates the owner into the target class while preserving output evaluation semantics.\n\n>>> from ommx import DecisionVariable, Instance, InstanceClass, Optimality, PreparationPolicy, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize\n... )\n>>> policy = PreparationPolicy.for_qubo(uniform_penalty_weight=2.0)\n>>> assert instance.prepare(InstanceClass.qubo(), policy) is None\n>>> assert InstanceClass.qubo().contains(instance)\n>>> assert instance.sense == Sense.Minimize\n>>> assert instance.objective.evaluate({0: 0}) == 2.0\n>>> solution = instance.evaluate({0: 0})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0)\n>>> assert instance.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified", "signatures": [ { "parameters": [ @@ -10689,7 +10793,7 @@ }, { "name": "random_samples", - "doc": "Generate random samples for this instance.\n\nThe generated samples will contain ``num_samples`` sample entries divided into\n``num_different_samples`` groups, where each group shares the same state but has\ndifferent sample IDs.\n\n**Args:**\n- `rng`: Random number generator\n- `num_different_samples`: Number of different states to generate\n- `num_samples`: Total number of samples to generate\n- `max_sample_id`: Maximum sample ID (default: ``num_samples``)\n\n**Returns:**\nSamples object\n\nRaises {class}`ValueError` if the requested state groups cannot\npartition the samples or the inclusive sample-ID range is too small.\n`num_different_samples=0` is valid only when `num_samples=0`.\n\n# Examples\n\nGenerate samples for a simple instance:\n\n```python\n>>> from ommx import Instance, DecisionVariable, Rng\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[(sum(x) <= 2).set_id(0)],\n... sense=Instance.MAXIMIZE,\n... )\n\n>>> rng = Rng()\n>>> samples = instance.random_samples(rng, num_different_samples=2, num_samples=5)\n>>> samples.num_samples()\n5\n```", + "doc": "Generate random samples for this instance.\n\nThe generated samples will contain ``num_samples`` sample entries divided into\n``num_different_samples`` groups, where each group shares the same state but has\ndifferent sample IDs.\n\n**Args:**\n- `rng`: Random number generator\n- `num_different_samples`: Number of different states to generate\n- `num_samples`: Total number of samples to generate\n- `max_sample_id`: Maximum sample ID (default: ``num_samples``)\n\n**Returns:**\nSamples object\n\nRaises {class}`ValueError` if the requested state groups cannot\npartition the samples or the inclusive sample-ID range is too small.\n`num_different_samples=0` is valid only when `num_samples=0`.\n\n# Examples\n\nGenerate samples for a simple instance:\n\n>>> from ommx import DecisionVariable, Instance, Rng, Sense\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={0: sum(x) <= 2},\n... sense=Sense.Maximize,\n... )\n\n>>> rng = Rng()\n>>> samples = instance.random_samples(rng, num_different_samples=2, num_samples=5)\n>>> samples.num_samples()\n5", "signatures": [ { "parameters": [ @@ -10762,7 +10866,7 @@ }, { "name": "random_state", - "doc": "Generate a random state for this instance using the provided random number generator.\n\nThis method generates random values only for variables that are actually used in the\nobjective function or constraints, as determined by decision variable usage.\nGenerated values respect the bounds of each variable type.\n\n**Args:**\n- `rng`: Random number generator to use for generating the state.\n\n**Returns:**\nA randomly generated state that satisfies the variable bounds of this instance.\nOnly contains values for variables that are used in the problem.\n\n# Examples\n\nGenerate random state only for used variables\n\n```python\n>>> from ommx import Instance, DecisionVariable, Rng\n>>> x = [DecisionVariable.binary(i) for i in range(5)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=x[0] + x[1],\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n\n>>> rng = Rng()\n>>> state = instance.random_state(rng)\n```\n\nOnly used variables have values\n\n```python\n>>> set(state.entries.keys())\n{0, 1}\n```\n\nValues respect binary bounds\n\n```python\n>>> all(state.entries[i] in [0.0, 1.0] for i in state.entries)\nTrue\n```", + "doc": "Generate a random state for this instance using the provided random number generator.\n\nThis method generates random values only for variables that are actually used in the\nobjective function or constraints, as determined by decision variable usage.\nGenerated values respect the bounds of each variable type.\n\n**Args:**\n- `rng`: Random number generator to use for generating the state.\n\n**Returns:**\nA randomly generated state that satisfies the variable bounds of this instance.\nOnly contains values for variables that are used in the problem.\n\n# Examples\n\nGenerate random state only for used variables\n\n>>> from ommx import DecisionVariable, Instance, Rng, Sense\n>>> x = [DecisionVariable.binary(i) for i in range(5)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=x[0] + x[1],\n... constraints={},\n... sense=Sense.Maximize,\n... )\n\n>>> rng = Rng()\n>>> state = instance.random_state(rng)\n\nOnly used variables have values\n\n>>> set(state.entries.keys())\n{0, 1}\n\nValues respect binary bounds\n\n>>> all(state.entries[i] in [0.0, 1.0] for i in state.entries)\nTrue", "signatures": [ { "parameters": [ @@ -10793,7 +10897,7 @@ }, { "name": "reduce_binary_power", - "doc": "Reduce binary powers in the instance.\n\nThis method replaces binary powers in the instance with their equivalent linear expressions.\nFor binary variables, $x^n = x$ for any $n \\geq 1$, so we can reduce higher powers to linear terms.\n\n**Returns:**\n``True`` if any reduction was performed, ``False`` otherwise.\n\n# Examples\n\nConsider an instance with binary variables and quadratic terms:\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=x[0] * x[0] + x[0] * x[1],\n... constraints=[],\n... sense=Instance.MINIMIZE,\n... )\n>>> instance.objective\nFunction(x0*x0 + x0*x1)\n```\n\nAfter reducing binary powers, x0^2 becomes x0:\n\n```python\n>>> changed = instance.reduce_binary_power()\n>>> changed\nTrue\n>>> instance.objective\nFunction(x0*x1 + x0)\n```\n\nRunning it again should not change anything:\n\n```python\n>>> changed = instance.reduce_binary_power()\n>>> changed\nFalse\n```", + "doc": "Reduce binary powers in the instance.\n\nThis method replaces binary powers in the instance with their equivalent linear expressions.\nFor binary variables, $x^n = x$ for any $n \\geq 1$, so we can reduce higher powers to linear terms.\n\n**Returns:**\n``True`` if any reduction was performed, ``False`` otherwise.\n\n# Postconditions\n\nReduction simplifies only active expressions while preserving output evaluation semantics.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> assert instance.reduce_binary_power()\n>>> assert instance.objective.evaluate({0: 1}) == -1.0\n>>> solution = instance.evaluate({0: 1})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0)\n>>> assert not instance.reduce_binary_power()", "signatures": [ { "parameters": [], @@ -10809,7 +10913,7 @@ }, { "name": "relax_constraint", - "doc": "Remove a constraint from the instance.\n\nThe removed constraint is stored in {attr}`~ommx.Instance.removed_constraints`, and can be restored by {meth}`~ommx.Instance.restore_constraint`.\n\n**Args:**\n- `constraint_id`: The ID of the constraint to remove.\n- `reason`: The reason why the constraint is removed.\n- `parameters`: Additional parameters to describe the reason.\n\n# Examples\n\nRelax constraint, and restore it.\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[(sum(x) == 3).set_id(1)],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.constraints\n[Constraint(x0 + x1 + x2 - 3 == 0)]\n```\n\n```python\n>>> instance.relax_constraint(1, \"manual relaxation\")\n>>> instance.constraints\n[]\n>>> instance.removed_constraints\n[RemovedConstraint(x0 + x1 + x2 - 3 == 0, reason=manual relaxation)]\n```\n\n```python\n>>> instance.restore_constraint(1)\n>>> instance.constraints\n[Constraint(x0 + x1 + x2 - 3 == 0)]\n>>> instance.removed_constraints\n[]\n```", + "doc": "Remove a constraint from the instance.\n\nThe removed constraint is stored in {attr}`~ommx.Instance.removed_constraints`, and can be restored by {meth}`~ommx.Instance.restore_constraint`.\n\n**Args:**\n- `constraint_id`: The ID of the constraint to remove.\n- `reason`: The reason why the constraint is removed.\n- `parameters`: Additional parameters to describe the reason.\n\n# Examples\n\nRelax constraint, and restore it.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={1: sum(x) == 3},\n... sense=Sense.Maximize,\n... )\n>>> assert set(instance.constraints) == {1}\n\n>>> instance.relax_constraint(1, \"manual relaxation\")\n>>> assert not instance.constraints\n>>> assert set(instance.removed_constraints) == {1}\n\n>>> instance.restore_constraint(1)\n>>> assert set(instance.constraints) == {1}\n>>> assert not instance.removed_constraints", "signatures": [ { "parameters": [ @@ -10934,7 +11038,7 @@ }, { "name": "required_ids", - "doc": "Get the set of decision variable IDs used in the objective and remaining constraints.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.required_ids()\n{0, 1, 2}\n```", + "doc": "Get the decision variable IDs required by the active formulation.\n\n# Postconditions\n\nIDs referenced only by preserved output semantics are not required solver inputs.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> fixed = instance.partial_evaluate({0: 1})\n>>> assert fixed.required_ids() == set()\n>>> assert fixed.evaluate({}).objective == 1.0", "signatures": [ { "parameters": [], @@ -11046,7 +11150,7 @@ }, { "name": "stats", - "doc": "Get statistics about the instance.\n\nReturns a dictionary containing counts of decision variables and constraints\ncategorized by kind, usage, and status.\n\n**Returns:**\nA dictionary with the following structure:\n\n```text\n{\n \"decision_variables\": {\n \"total\": int,\n \"by_kind\": {\n \"binary\": int,\n \"integer\": int,\n \"continuous\": int,\n \"semi_integer\": int,\n \"semi_continuous\": int\n },\n \"by_usage\": {\n \"used_in_objective\": int,\n \"used_in_constraints\": int,\n \"used\": int,\n \"fixed\": int,\n \"dependent\": int,\n \"irrelevant\": int\n }\n },\n \"constraints\": {\n \"total\": int,\n \"active\": int,\n \"removed\": int\n }\n}\n```\n\n# Examples\n\n```python\n>>> from ommx import Instance\n>>> instance = Instance.minimize()\n>>> stats = instance.stats()\n>>> stats[\"decision_variables\"][\"total\"]\n0\n>>> stats[\"constraints\"][\"total\"]\n0\n```", + "doc": "Get statistics about the instance.\n\nReturns a dictionary containing counts of decision variables and constraints\ncategorized by kind, usage, and status.\n\n**Returns:**\nA dictionary with the following structure:\n\n```text\n{\n \"decision_variables\": {\n \"total\": int,\n \"by_kind\": {\n \"binary\": int,\n \"integer\": int,\n \"continuous\": int,\n \"semi_integer\": int,\n \"semi_continuous\": int\n },\n \"by_usage\": {\n \"used_in_objective\": int,\n \"used_in_constraints\": int,\n \"used\": int,\n \"fixed\": int,\n \"dependent\": int,\n \"irrelevant\": int\n }\n },\n \"constraints\": {\n \"total\": int,\n \"active\": int,\n \"removed\": int\n }\n}\n```\n\n# Examples\n\n>>> from ommx import Instance\n>>> instance = Instance.minimize()\n>>> stats = instance.stats()\n>>> stats[\"decision_variables\"][\"total\"]\n0\n>>> stats[\"constraints\"][\"total\"]\n0", "signatures": [ { "parameters": [], @@ -11062,7 +11166,7 @@ }, { "name": "substitute", - "doc": "Substitute decision variables with function expressions (in-place).\n\nReplaces each given decision variable with the provided function in the\nobjective and all active constraints. This is the general substitution\nmechanism behind {meth}`~ommx.Instance.log_encode`, exposed so that\nusers can implement their own integer encodings (e.g. unary, one-hot).\n\n**Args:**\n- `assignments`: A dict mapping decision variable IDs to the function\n expressions that should replace them.\n\n**Important:**\nThis method performs an algebraic rewrite. It does not automatically\ntranslate the substituted variable's bound or kind into constraints on\nthe replacement expression. For example, substituting a binary variable\n``x`` with ``y + z`` does not add ``0 <= y + z <= 1``, and substituting\nan integer variable does not ensure that the replacement expression is\nintegral. If the substitution must preserve the optimization problem,\nthe caller must provide a domain-preserving encoding or add the required\nlinking and bound constraints explicitly.\n\nRaises ``ValueError`` on cyclic or recursive assignments, or when\nsubstituting a variable that is a member of an indicator, one-hot, or\nSOS1 constraint.\n\n# Examples\n\nEncode an integer variable x0 in range $[0, 3]$ into two binary\nvariables by hand, instead of using {meth}`~ommx.Instance.log_encode`:\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = DecisionVariable.integer(0, lower=0, upper=3, name=\"x\")\n>>> b = [DecisionVariable.binary(i, name=\"b\", subscripts=[i]) for i in (1, 2)]\n>>> instance = Instance.from_components(\n... decision_variables=[x, *b],\n... objective=x,\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.substitute({0: b[0] + 2 * b[1]})\n>>> instance.objective\nFunction(x1 + 2*x2)\n```", + "doc": "Substitute decision variables with function expressions (in-place).\n\nReplaces each given decision variable with the provided function in the\nobjective and all active constraints. This is the general substitution\nmechanism behind {meth}`~ommx.Instance.log_encode`, exposed so that\nusers can implement their own integer encodings (e.g. unary, one-hot).\n\n**Args:**\n- `assignments`: A dict mapping decision variable IDs to the function\n expressions that should replace them.\n\n**Important:**\nThis method performs an algebraic rewrite. It does not automatically\ntranslate the substituted variable's bound or kind into constraints on\nthe replacement expression. For example, substituting a binary variable\n``x`` with ``y + z`` does not add ``0 <= y + z <= 1``, and substituting\nan integer variable does not ensure that the replacement expression is\nintegral. If the substitution must preserve the optimization problem,\nthe caller must provide a domain-preserving encoding or add the required\nlinking and bound constraints explicitly.\n\nRaises ``ValueError`` on cyclic or recursive assignments, or when\nsubstituting a variable that is a member of an indicator, one-hot, or\nSOS1 constraint.\n\n# Postconditions\n\nSubstitution rewrites the active objective while output evaluation restores the substituted variable value.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> b = DecisionVariable.binary(1)\n>>> instance = Instance.from_components(\n... decision_variables=[x, b], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> instance.substitute({0: b})\n>>> assert instance.required_ids() == {1}\n>>> assert instance.objective.evaluate({1: 1}) == -1.0\n>>> solution = instance.evaluate({1: 1})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0)", "signatures": [ { "parameters": [ @@ -11104,7 +11208,7 @@ }, { "name": "to_hubo", - "doc": "Convert the instance to a HUBO format.\n\nThis is a **Driver API** for HUBO conversion calling single-purpose methods in order:\n\n1. Convert the instance to a minimization problem by {meth}`~ommx.Instance.as_minimization_problem`.\n2. Check continuous variables and raise error if exists.\n3. Convert inequality constraints\n\n * Try {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack` first with given ``inequality_integer_slack_max_range``.\n * If failed, {meth}`~ommx.Instance.add_integer_slack_to_inequality`\n\n4. Convert to HUBO with (uniform) penalty method\n\n * If ``penalty_weights`` is given (in ``dict[constraint_id, weight]`` form), use {meth}`~ommx.Instance.penalty_method` with the given weights.\n * If ``uniform_penalty_weight`` is given, use {meth}`~ommx.Instance.uniform_penalty_method` with the given weight.\n * If both are None, defaults to ``uniform_penalty_weight = 1.0``.\n\n5. Log-encode integer variables by {meth}`~ommx.Instance.log_encode`.\n6. Finally convert to HUBO format by {meth}`~ommx.Instance.as_hubo_format`.\n\nPlease see the documentation for {meth}`~ommx.Instance.to_qubo` for more information, or the\ndocumentation for each individual method for additional details. The\ndifference between this and {meth}`~ommx.Instance.to_qubo` is that this method isn't\nrestricted to quadratic or linear problems. If you want to customize the\nconversion, use the individual methods above manually.", + "doc": "Convert the instance to a HUBO format.\n\n# Postconditions\n\nThe driver is equivalent to HUBO Preparation followed by active-objective formatting and retains the input output semantics.\n\n>>> import copy\n>>> from ommx import DecisionVariable, Instance, InstanceClass, PreparationPolicy, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize\n... )\n>>> explicit = copy.copy(instance)\n>>> policy = PreparationPolicy.for_hubo(uniform_penalty_weight=2.0)\n>>> _ = explicit.prepare(InstanceClass.hubo(), policy)\n>>> expected = explicit.as_hubo_format()\n>>> actual = instance.to_hubo(uniform_penalty_weight=2.0)\n>>> assert actual == expected\n>>> assert InstanceClass.hubo().contains(instance)\n>>> assert instance.sense == Sense.Minimize\n>>> assert instance.objective.evaluate({0: 0}) == 2.0\n>>> solution = instance.evaluate({0: 0})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0)\n\n# Errors\n\nMutually exclusive penalty options raise ``ValueError`` before mutating the instance.\n\n>>> unchanged = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize\n... )\n>>> before = unchanged.to_v2_bytes()\n>>> try:\n... unchanged.to_hubo(uniform_penalty_weight=1.0, penalty_weights={7: 2.0})\n... except ValueError:\n... pass\n... else:\n... raise AssertionError(\"mutually exclusive penalty options were accepted\")\n>>> assert unchanged.to_v2_bytes() == before", "signatures": [ { "parameters": [ @@ -11191,7 +11295,7 @@ }, { "name": "to_qubo", - "doc": "Convert the instance to a QUBO format.\n\nThis is a **Driver API** for QUBO conversion calling single-purpose methods in order:\n\n1. Convert the instance to a minimization problem by {meth}`~ommx.Instance.as_minimization_problem`.\n2. Check continuous variables and raise error if exists.\n3. Convert inequality constraints\n\n * Try {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack` first with given ``inequality_integer_slack_max_range``.\n * If failed, {meth}`~ommx.Instance.add_integer_slack_to_inequality`\n\n4. Convert to QUBO with (uniform) penalty method\n\n * If ``penalty_weights`` is given (in ``dict[constraint_id, weight]`` form), use {meth}`~ommx.Instance.penalty_method` with the given weights.\n * If ``uniform_penalty_weight`` is given, use {meth}`~ommx.Instance.uniform_penalty_method` with the given weight.\n * If both are None, defaults to ``uniform_penalty_weight = 1.0``.\n\n5. Log-encode integer variables by {meth}`~ommx.Instance.log_encode`.\n6. Finally convert to QUBO format by {meth}`~ommx.Instance.as_qubo_format`.\n\nPlease see the document of each method for details.\nIf you want to customize the conversion, use the methods above manually.\n\n# Examples\n\nLet's consider a maximization problem with two integer variables $x_0, x_1 \\in [0, 2]$ subject to an inequality:\n\n$$\\max \\; x_0 + x_1 \\quad \\text{s.t.} \\quad x_0 + 2 x_1 \\leq 3$$\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.integer(i, lower=0, upper=2, name=\"x\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[(x[0] + 2*x[1] <= 3).set_id(0)],\n... sense=Instance.MAXIMIZE,\n... )\n```\n\nConvert into QUBO format\n\n```python\n>>> qubo, offset = instance.to_qubo()\n>>> qubo\n{(3, 3): -6.0, (3, 4): 2.0, (3, 5): 4.0, (3, 6): 4.0, (3, 7): 2.0, (3, 8): 4.0, (4, 4): -6.0, (4, 5): 4.0, (4, 6): 4.0, (4, 7): 2.0, (4, 8): 4.0, (5, 5): -9.0, (5, 6): 8.0, (5, 7): 4.0, (5, 8): 8.0, (6, 6): -9.0, (6, 7): 4.0, (6, 8): 8.0, (7, 7): -5.0, (7, 8): 4.0, (8, 8): -8.0}\n>>> offset\n9.0\n```\n\nFor the maximization problem, the sense is converted to minimization for generating QUBO, and then converted back to maximization.\n\n```python\n>>> instance.sense == Instance.MAXIMIZE\nTrue\n```", + "doc": "Convert the instance to a QUBO format.\n\n# Postconditions\n\nThe driver is equivalent to QUBO Preparation followed by active-objective formatting and retains the input output semantics.\n\n>>> import copy\n>>> from ommx import DecisionVariable, Instance, InstanceClass, PreparationPolicy, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize\n... )\n>>> explicit = copy.copy(instance)\n>>> policy = PreparationPolicy.for_qubo(uniform_penalty_weight=2.0)\n>>> _ = explicit.prepare(InstanceClass.qubo(), policy)\n>>> expected = explicit.as_qubo_format()\n>>> actual = instance.to_qubo(uniform_penalty_weight=2.0)\n>>> assert actual == expected\n>>> assert InstanceClass.qubo().contains(instance)\n>>> assert instance.sense == Sense.Minimize\n>>> assert instance.objective.evaluate({0: 0}) == 2.0\n>>> solution = instance.evaluate({0: 0})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0)\n\n# Errors\n\nMutually exclusive penalty options raise ``ValueError`` before mutating the instance.\n\n>>> unchanged = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize\n... )\n>>> before = unchanged.to_v2_bytes()\n>>> try:\n... unchanged.to_qubo(uniform_penalty_weight=1.0, penalty_weights={7: 2.0})\n... except ValueError:\n... pass\n... else:\n... raise AssertionError(\"mutually exclusive penalty options were accepted\")\n>>> assert unchanged.to_v2_bytes() == before", "signatures": [ { "parameters": [ @@ -11278,7 +11382,7 @@ }, { "name": "to_v1_bytes", - "doc": "", + "doc": "Serialize this instance in the OMMX v1 wire format.\n\n# Errors\n\nSerialization raises ``RuntimeError`` when distinct output semantics cannot be represented by v1.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> try:\n... instance.to_v1_bytes()\n... except RuntimeError:\n... pass\n... else:\n... raise AssertionError(\"v1 serialization accepted distinct output semantics\")", "signatures": [ { "parameters": [], @@ -11294,7 +11398,7 @@ }, { "name": "to_v2_bytes", - "doc": "", + "doc": "Serialize this instance in the OMMX v2 wire format.\n\n# Postconditions\n\nA v2 round-trip preserves both active and output objective semantics.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> restored = Instance.from_v2_bytes(instance.to_v2_bytes())\n>>> assert restored.sense == Sense.Minimize\n>>> assert restored.objective.evaluate({0: 1}) == -3.0\n>>> solution = restored.evaluate({0: 1})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0)", "signatures": [ { "parameters": [], @@ -11310,7 +11414,7 @@ }, { "name": "unary_encode", - "doc": "Unary-encode the integer decision variables.\n\nUnary encoding of an integer variable $x \\in [l, u]$ is to represent it\nby $u - l$ bits $b_j \\in \\{0, 1\\}$:\n\n$$x = l + \\sum_j b_j$$\n\nEvery bit configuration maps to a valid integer in the original range,\nso no encoding-validity penalty or linking constraint is added. This\ncosts linearly many auxiliary variables, so use it for narrow integer\nranges.\n\n**Args:**\n- `decision_variable_ids`: The IDs of the integer decision variables to unary-encode.\n If not specified (or empty), all used integer variables are unary-encoded.\n- `max_range`: Maximum allowed `upper - lower` range for each encoded\n variable. This also bounds the number of auxiliary binary variables\n introduced per integer variable.\n- `atol`: Optional absolute tolerance used when normalizing integer\n bounds before encoding. If None, uses the default tolerance.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = DecisionVariable.integer(0, lower=2, upper=5, name=\"x\")\n>>> instance = Instance.from_components(\n... decision_variables=[x],\n... objective=x,\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.unary_encode({0})\n>>> instance.objective\nFunction(x1 + x2 + x3 + 2)\n```", + "doc": "Unary-encode the integer decision variables.\n\nUnary encoding of an integer variable $x \\in [l, u]$ is to represent it\nby $u - l$ bits $b_j \\in \\{0, 1\\}$:\n\n$$x = l + \\sum_j b_j$$\n\nEvery bit configuration maps to a valid integer in the original range,\nso no encoding-validity penalty or linking constraint is added. This\ncosts linearly many auxiliary variables, so use it for narrow integer\nranges.\n\n**Args:**\n- `decision_variable_ids`: The IDs of the integer decision variables to unary-encode.\n If not specified (or empty), all used integer variables are unary-encoded.\n- `max_range`: Maximum allowed `upper - lower` range for each encoded\n variable. This also bounds the number of auxiliary binary variables\n introduced per integer variable.\n- `atol`: Optional absolute tolerance used when normalizing integer\n bounds before encoding. If None, uses the default tolerance.\n\n# Postconditions\n\nEncoding rewrites the active objective while output evaluation restores the encoded integer value.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.integer(0, lower=2, upper=5, name=\"x\")\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> instance.unary_encode({0})\n>>> encoded_ids = instance.required_ids()\n>>> assert len(encoded_ids) == 3\n>>> state = {variable_id: 1 for variable_id in encoded_ids}\n>>> assert instance.objective.evaluate(state) == -5.0\n>>> solution = instance.evaluate(state)\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 5.0)", "signatures": [ { "parameters": [ @@ -11375,7 +11479,7 @@ }, { "name": "uniform_penalty_method", - "doc": "Convert to a parametric unconstrained instance by penalty method with uniform weight.\n\nRoughly, this converts a constrained problem:\n\n$$\\min_x f(x) \\quad \\text{s.t.} \\quad g_i(x) = 0 \\; (\\forall i), \\quad h_j(x) \\leq 0 \\; (\\forall j)$$\n\nto an unconstrained problem with a parameter:\n\n$$\\min_x f(x) + \\lambda \\left( \\sum_i g_i(x)^2 + \\sum_j h_j(x)^2 \\right)$$\n\nwhere $\\lambda$ is the uniform penalty weight parameter for all constraints.\n\nThe removed constraints are stored in {attr}`~ommx.ParametricInstance.removed_constraints`.\n\n> Note: This method converts inequality constraints $h(x) \\leq 0$ to $|h(x)|^2$ not to $\\max(0, h(x))^2$.\n> This means the penalty is enforced even for $h(x) < 0$ cases, and $h(x) = 0$ is unfairly favored.\n> This feature is intended to use with {meth}`~ommx.Instance.add_integer_slack_to_inequality`.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[sum(x) == 3],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.objective\nFunction(x0 + x1 + x2)\n>>> pi = instance.uniform_penalty_method()\n```\n\nThe constraint is put in removed_constraints\n\n```python\n>>> pi.constraints\n[]\n>>> len(pi.removed_constraints)\n1\n>>> pi.removed_constraints[0]\nRemovedConstraint(x0 + x1 + x2 - 3 == 0, reason=ommx.Instance.uniform_penalty_method)\n```\n\nThere is only one parameter in the instance\n\n```python\n>>> len(pi.parameters)\n1\n>>> p = pi.parameters[0]\n>>> p.id\n3\n>>> p.name\n'uniform_penalty_weight'\n```", + "doc": "Convert to a parametric unconstrained instance by penalty method with uniform weight.\n\nRoughly, this converts a constrained problem:\n\n$$\\min_x f(x) \\quad \\text{s.t.} \\quad g_i(x) = 0 \\; (\\forall i), \\quad h_j(x) \\leq 0 \\; (\\forall j)$$\n\nto an unconstrained problem with a parameter:\n\n$$\\min_x f(x) + \\lambda \\left( \\sum_i g_i(x)^2 + \\sum_j h_j(x)^2 \\right)$$\n\nwhere $\\lambda$ is the uniform penalty weight parameter for all constraints.\n\nThe removed constraints are stored in {attr}`~ommx.ParametricInstance.removed_constraints`.\n\n> Note: This method converts inequality constraints $h(x) \\leq 0$ to $|h(x)|^2$ not to $\\max(0, h(x))^2$.\n> This means the penalty is enforced even for $h(x) < 0$ cases, and $h(x) = 0$ is unfairly favored.\n> This feature is intended to use with {meth}`~ommx.Instance.add_integer_slack_to_inequality`.\n\n# Postconditions\n\nMaterialization evaluates uniform-penalty energy actively while retaining the pre-penalty objective for output and invalidating optimality transport.\n\n>>> from ommx import DecisionVariable, Instance, Optimality, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize\n... )\n>>> parametric = instance.uniform_penalty_method()\n>>> parameter_id = parametric.parameters[0].id\n>>> prepared = parametric.with_parameters({parameter_id: 2.0})\n>>> assert parametric.constraints == {}\n>>> assert 7 in parametric.removed_constraints\n>>> assert prepared.objective.evaluate({0: 0}) == 2.0\n>>> solution = prepared.evaluate({0: 0})\n>>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False)\n>>> assert prepared.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified", "signatures": [ { "parameters": [], @@ -11724,7 +11828,7 @@ }, { "name": "objective", - "doc": "", + "doc": "Active objective used by the solver-facing formulation.\n\n# Postconditions\n\nAssignment replaces the active objective and rebases subsequent output evaluation onto it.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> assert instance.objective.evaluate({0: 1}) == -1.0\n>>> instance.objective = 2 * x\n>>> solution = instance.evaluate({0: 1})\n>>> assert instance.sense == Sense.Minimize\n>>> assert (solution.sense, solution.objective) == (Sense.Minimize, 2.0)", "type_": { "display": "Function", "link_target": { @@ -11849,7 +11953,7 @@ }, { "name": "sense", - "doc": "", + "doc": "Active optimization sense used by the solver-facing formulation.\n\n# Postconditions\n\nThe property reports the active sense even when evaluation uses a distinct output sense.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> assert instance.sense == Sense.Minimize\n>>> assert instance.evaluate({0: 1}).sense == Sense.Maximize", "type_": { "display": "Sense", "link_target": null, @@ -12022,6 +12126,38 @@ "is_async": false, "deprecated": null }, + { + "name": "hubo", + "doc": "Class of minimization HUBO formulations accepted by\n{meth}`~ommx.Instance.as_hubo_format` after Preparation.\n\n# Postconditions\n\nThe target accepts unconstrained minimization Binary HUBO formulations and rejects models outside that class.\n\n>>> from ommx import DecisionVariable, Instance, InstanceClass, Sense\n>>> x = DecisionVariable.binary(0)\n>>> linear = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize\n... )\n>>> cubic = Instance.from_components(\n... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Minimize\n... )\n>>> maximizing = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> continuous = DecisionVariable.continuous(1)\n>>> non_binary = Instance.from_components(\n... decision_variables=[continuous], objective=continuous, constraints={}, sense=Sense.Minimize\n... )\n>>> constrained = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={0: x == 1}, sense=Sense.Minimize\n... )\n>>> target = InstanceClass.hubo()\n>>> assert target.contains(linear)\n>>> assert target.contains(cubic)\n>>> assert not target.contains(maximizing)\n>>> assert not target.contains(non_binary)\n>>> assert not target.contains(constrained)", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "InstanceClass", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "qubo", + "doc": "Class of minimization QUBO formulations accepted by\n{meth}`~ommx.Instance.as_qubo_format` after Preparation.\n\n# Postconditions\n\nThe target accepts unconstrained minimization QUBO formulations and rejects models outside that class.\n\n>>> from ommx import DecisionVariable, Instance, InstanceClass, Sense\n>>> x = DecisionVariable.binary(0)\n>>> linear = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize\n... )\n>>> quadratic = Instance.from_components(\n... decision_variables=[x], objective=x * x, constraints={}, sense=Sense.Minimize\n... )\n>>> cubic = Instance.from_components(\n... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Minimize\n... )\n>>> maximizing = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> continuous = DecisionVariable.continuous(1)\n>>> non_binary = Instance.from_components(\n... decision_variables=[continuous], objective=continuous, constraints={}, sense=Sense.Minimize\n... )\n>>> constrained = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={0: x == 1}, sense=Sense.Minimize\n... )\n>>> target = InstanceClass.qubo()\n>>> assert target.contains(linear)\n>>> assert target.contains(quadratic)\n>>> assert not target.contains(cubic)\n>>> assert not target.contains(maximizing)\n>>> assert not target.contains(non_binary)\n>>> assert not target.contains(constrained)", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "InstanceClass", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "union", "doc": "Return the finite union of two instance classes.", @@ -12850,7 +12986,7 @@ { "kind": "Class", "name": "Linear", - "doc": "Linear function of decision variables.\n\nA linear function has the form: $c_0 + \\sum_i c_i x_i$ where $x_i$ are decision variables\nand $c_i$ are coefficients.\n\n# Examples\n\nCreate a linear function `f(x₁, x₂) = 2x₁ + 3x₂ + 1`:\n\n```python\n>>> f = Linear(terms={1: 2, 2: 3}, constant=1)\n```\n\nOr create via DecisionVariable arithmetic:\n\n```python\n>>> x1 = DecisionVariable.integer(1)\n>>> x2 = DecisionVariable.integer(2)\n>>> g = 2*x1 + 3*x2 + 1\n```\n\nCompare two linear functions with tolerance:\n\n```python\n>>> f.almost_equal(g, atol=1e-12)\nTrue\n```\n\nNote that `==` creates an equality Constraint, not a boolean:\n\n```python\n>>> constraint = f == g # Returns Constraint, not bool\n```", + "doc": "Linear function of decision variables.\n\nA linear function has the form: $c_0 + \\sum_i c_i x_i$ where $x_i$ are decision variables\nand $c_i$ are coefficients.\n\n# Examples\n\nCreate a linear function `f(x₁, x₂) = 2x₁ + 3x₂ + 1`:\n\n>>> f = Linear(terms={1: 2, 2: 3}, constant=1)\n\nOr create via DecisionVariable arithmetic:\n\n>>> x1 = DecisionVariable.integer(1)\n>>> x2 = DecisionVariable.integer(2)\n>>> g = 2*x1 + 3*x2 + 1\n\nCompare two linear functions with tolerance:\n\n>>> f.almost_equal(g, atol=1e-12)\nTrue\n\nNote that `==` creates an equality Constraint, not a boolean:\n\n>>> constraint = f == g # Returns Constraint, not bool", "bases": [], "methods": [ { @@ -14868,6 +15004,80 @@ ], "deprecated": null }, + { + "kind": "Class", + "name": "ObjectivePreparation", + "doc": "Convert the active objective to ``target`` during Preparation.\n\n# Invariants\n\nThe immutable target records the solver-facing sense requested by Preparation.\n\n>>> from ommx import ObjectivePreparation, Sense\n>>> preparation = ObjectivePreparation(target=Sense.Minimize)\n>>> assert preparation.target == Sense.Minimize", + "bases": [], + "methods": [ + { + "name": "__eq__", + "doc": "", + "signatures": [ + { + "parameters": [ + { + "name": "other", + "type_": { + "display": "object", + "link_target": null, + "children": [] + }, + "default": null + } + ], + "return_type": { + "display": "bool", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "__new__", + "doc": "", + "signatures": [ + { + "parameters": [ + { + "name": "target", + "type_": { + "display": "Sense", + "link_target": null, + "children": [] + }, + "default": null + } + ], + "return_type": { + "display": "ObjectivePreparation", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + } + ], + "attributes": [ + { + "name": "target", + "doc": "", + "type_": { + "display": "Sense", + "link_target": null, + "children": [] + }, + "is_property": true, + "is_readonly": true + } + ], + "deprecated": null + }, { "kind": "Class", "name": "OneHotConstraint", @@ -15290,7 +15500,7 @@ { "kind": "Class", "name": "Parameter", - "doc": "Parameter in an optimization problem.\n\nParameters are values that are fixed during optimization but may vary between different\nruns or scenarios. They share the same ID space with decision variables.\n\nNote that this object overloads `==` for creating a constraint, not for equality comparison.\n\n# Examples\n\n```python\n>>> p = Parameter(1, name=\"penalty\")\n>>> x = DecisionVariable.integer(2)\n>>> x + p # Returns Linear expression\nLinear(...)\n```", + "doc": "Parameter in an optimization problem.\n\nParameters are values that are fixed during optimization but may vary between different\nruns or scenarios. They share the same ID space with decision variables.\n\nNote that this object overloads `==` for creating a constraint, not for equality comparison.\n\n# Examples\n\n>>> p = Parameter(1, name=\"penalty\")\n>>> x = DecisionVariable.integer(2)\n>>> x + p # Returns Linear expression\nLinear(...)", "bases": [], "methods": [ { @@ -17637,7 +17847,7 @@ }, { "name": "substitute", - "doc": "Substitute decision variables with function expressions (in-place).\n\nReplaces each given decision variable with the provided function in the\nobjective and all active constraints. Replacement functions may still\nreference parameters; those parameter references remain in this\nparametric instance and are evaluated later by {meth}`with_parameters`.\nAssignment targets must be existing decision variable IDs. Replacement\nexpressions may only reference existing decision variable or parameter\nIDs; parameter IDs cannot be assignment targets.\n\n**Args:**\n- `assignments`: A dict mapping decision variable IDs to the function\n expressions that should replace them.\n\n**Important:**\nThis method performs an algebraic rewrite. It does not automatically\ntranslate the substituted variable's bound or kind into constraints on\nthe replacement expression. If the substitution must preserve the\noptimization problem, the caller must provide a domain-preserving\nencoding or add the required linking and bound constraints explicitly.\n\nRaises ``ValueError`` on cyclic or recursive assignments, undefined\nIDs, when a parameter ID is used as an assignment target, or when\nsubstituting a variable that is a member of an indicator, one-hot, or\nSOS1 constraint.", + "doc": "Substitute decision variables with function expressions (in-place).\n\nReplaces each given decision variable with the provided function in the\nobjective and all active constraints. Replacement functions may still\nreference parameters; those parameter references remain in this\nparametric instance and are evaluated later by {meth}`with_parameters`.\nAssignment targets must be existing decision variable IDs. Replacement\nexpressions may only reference existing decision variable or parameter\nIDs; parameter IDs cannot be assignment targets.\n\n**Args:**\n- `assignments`: A dict mapping decision variable IDs to the function\n expressions that should replace them.\n\n**Important:**\nThis method performs an algebraic rewrite. It does not automatically\ntranslate the substituted variable's bound or kind into constraints on\nthe replacement expression. If the substitution must preserve the\noptimization problem, the caller must provide a domain-preserving\nencoding or add the required linking and bound constraints explicitly.\n\nRaises ``ValueError`` on cyclic or recursive assignments, undefined\nIDs, when a parameter ID is used as an assignment target, or when\nsubstituting a variable that is a member of an indicator, one-hot, or\nSOS1 constraint.\n\n# Postconditions\n\nSubstitution rewrites active expressions while materialized output evaluation restores the substituted variable.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> b = DecisionVariable.binary(1)\n>>> source = Instance.from_components(\n... decision_variables=[x, b], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert source.convert_active_objective(Sense.Minimize)\n>>> parametric = source.as_parametric_instance()\n>>> parametric.substitute({0: b})\n>>> materialized = parametric.with_parameters({})\n>>> assert materialized.required_ids() == {1}\n>>> assert materialized.objective.evaluate({1: 1}) == -1.0\n>>> solution = materialized.evaluate({1: 1})\n>>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0)", "signatures": [ { "parameters": [ @@ -17679,7 +17889,7 @@ }, { "name": "to_v1_bytes", - "doc": "", + "doc": "Serialize this parametric instance in the OMMX v1 wire format.\n\n# Errors\n\nSerialization raises ``RuntimeError`` when distinct output semantics cannot be represented by v1.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> instance = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize\n... )\n>>> assert instance.convert_active_objective(Sense.Minimize)\n>>> parametric = instance.as_parametric_instance()\n>>> try:\n... parametric.to_v1_bytes()\n... except RuntimeError:\n... pass\n... else:\n... raise AssertionError(\"v1 serialization accepted distinct output semantics\")", "signatures": [ { "parameters": [], @@ -17695,7 +17905,7 @@ }, { "name": "to_v2_bytes", - "doc": "", + "doc": "Serialize this parametric instance in the OMMX v2 wire format.\n\n# Postconditions\n\nA v2 round-trip preserves both active and output objective semantics through materialization.\n\n>>> from ommx import DecisionVariable, Instance, ParametricInstance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> source = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize\n... )\n>>> parametric = source.uniform_penalty_method()\n>>> parameter_id = parametric.parameters[0].id\n>>> restored = ParametricInstance.from_v2_bytes(parametric.to_v2_bytes())\n>>> materialized = restored.with_parameters({parameter_id: 2.0})\n>>> assert materialized.sense == Sense.Minimize\n>>> assert materialized.objective.evaluate({0: 0}) == 2.0\n>>> solution = materialized.evaluate({0: 0})\n>>> assert (solution.sense, solution.objective) == (Sense.Minimize, 0.0)", "signatures": [ { "parameters": [], @@ -17743,7 +17953,7 @@ }, { "name": "with_parameters", - "doc": "Substitute parameters to yield an instance.\n\nParameters can be provided as a dict mapping parameter IDs to their values.", + "doc": "Substitute parameters to yield an instance.\n\nParameters can be provided as a dict mapping parameter IDs to their values.\n\n# Postconditions\n\nMaterialization substitutes parameters in active energy while retaining the pre-penalty objective for output evaluation.\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = DecisionVariable.binary(0)\n>>> source = Instance.from_components(\n... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize\n... )\n>>> parametric = source.uniform_penalty_method()\n>>> parameter_id = parametric.parameters[0].id\n>>> materialized = parametric.with_parameters({parameter_id: 2.0})\n>>> assert materialized.objective.evaluate({0: 0}) == 2.0\n>>> solution = materialized.evaluate({0: 0})\n>>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False)", "signatures": [ { "parameters": [ @@ -18227,7 +18437,7 @@ { "kind": "Class", "name": "Polynomial", - "doc": "Polynomial function of decision variables.\n\nA polynomial function of arbitrary degree with terms of the form $c \\cdot x_1^{a_1} \\cdot x_2^{a_2} \\cdots$\nwhere $x_i$ are decision variables and $c$ is a coefficient.\n\n# Examples\n\nCreate via DecisionVariable operations:\n\n```python\n>>> x = DecisionVariable.integer(1)\n>>> y = DecisionVariable.integer(2)\n>>> p = x * x * y + x * y * y + 1 # Cubic polynomial\n```\n\nNote that `==`, `<=`, `>=` create Constraint objects:\n\n```python\n>>> constraint = p == 0 # Returns Constraint\n```", + "doc": "Polynomial function of decision variables.\n\nA polynomial function of arbitrary degree with terms of the form $c \\cdot x_1^{a_1} \\cdot x_2^{a_2} \\cdots$\nwhere $x_i$ are decision variables and $c$ is a coefficient.\n\n# Examples\n\nCreate via DecisionVariable operations:\n\n>>> x = DecisionVariable.integer(1)\n>>> y = DecisionVariable.integer(2)\n>>> p = x * x * y + x * y * y + 1 # Cubic polynomial\n\nNote that `==`, `<=`, `>=` create Constraint objects:\n\n>>> constraint = p == 0 # Returns Constraint", "bases": [], "methods": [ { @@ -19289,7 +19499,7 @@ { "kind": "Class", "name": "PreparationPolicy", - "doc": "", + "doc": "Select optional transformations applied by {meth}`~ommx.Instance.prepare`.\n\n# Invariants\n\nA default policy selects no Preparation phase.\n\n>>> from ommx import PreparationPolicy\n>>> policy = PreparationPolicy()\n>>> assert (\n... policy.special_constraints,\n... policy.objective,\n... policy.integer_slack,\n... policy.integer_encoding,\n... policy.fixed_penalty,\n... policy.binary_power_reduction,\n... ) == (None, None, None, None, None, None)", "bases": [], "methods": [ { @@ -19343,13 +19553,13 @@ } }, { - "name": "sense", + "name": "objective", "type_": { - "display": "Optional[SensePreparation]", + "display": "Optional[ObjectivePreparation]", "link_target": null, "children": [ { - "display": "SensePreparation", + "display": "ObjectivePreparation", "link_target": null, "children": [] } @@ -19413,6 +19623,176 @@ "kind": "Simple", "value": "None" } + }, + { + "name": "binary_power_reduction", + "type_": { + "display": "Optional[BinaryPowerPreparation]", + "link_target": null, + "children": [ + { + "display": "BinaryPowerPreparation", + "link_target": null, + "children": [] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "PreparationPolicy", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "for_hubo", + "doc": "Return a fresh policy for preparing an instance for HUBO formatting.\n\n``uniform_penalty_weight`` and ``penalty_weights`` override the default\nuniform penalty weight of 1.0 and are mutually exclusive. The keyed form\nmust cover exactly the active regular constraints at the penalty phase.\n``inequality_integer_slack_max_range`` defaults to 31 and configures both\nthe exact Integer-slack range and the fallback slack upper bound. Unlike\n{meth}`for_qubo`, this policy leaves Binary-power reduction disabled\nbecause HUBO accepts arbitrary polynomial degree.\n\n# Postconditions\n\nEach call returns a fresh complete HUBO policy with no Binary-power reduction and exact overrides.\n\n>>> from ommx import (\n... FixedPenaltyPreparation, IntegerEncodingPreparation,\n... IntegerSlackPreparation, ObjectivePreparation,\n... PreparationPolicy, Sense, SpecialConstraintKind,\n... SpecialConstraintPreparation,\n... )\n>>> expected_special = SpecialConstraintPreparation.lower_special_constraints(\n... kinds={\n... SpecialConstraintKind.Indicator,\n... SpecialConstraintKind.OneHot,\n... SpecialConstraintKind.Sos1,\n... }\n... )\n>>> first = PreparationPolicy.for_hubo(inequality_integer_slack_max_range=17)\n>>> second = PreparationPolicy.for_hubo(inequality_integer_slack_max_range=17)\n>>> assert first is not second\n>>> assert first.special_constraints == expected_special\n>>> assert first.objective == ObjectivePreparation(target=Sense.Minimize)\n>>> assert first.integer_slack == IntegerSlackPreparation(max_integer_range=17, slack_upper_bound=17)\n>>> assert first.integer_encoding == IntegerEncodingPreparation.log_encode_all_used_integers()\n>>> assert first.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0)\n>>> assert first.binary_power_reduction is None\n>>> first.fixed_penalty = None\n>>> assert second.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0)\n>>> uniform = PreparationPolicy.for_hubo(uniform_penalty_weight=4.0)\n>>> assert uniform.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=4.0)\n\n# Errors\n\nSupplying uniform and keyed penalty weights together raises ``ValueError``.\n\n>>> try:\n... PreparationPolicy.for_hubo(uniform_penalty_weight=1.0, penalty_weights={3: 2.0})\n... except ValueError as error:\n... assert \"Both uniform_penalty_weight\" in str(error)\n... else:\n... raise AssertionError(\"mutually exclusive penalty options were accepted\")", + "signatures": [ + { + "parameters": [ + { + "name": "uniform_penalty_weight", + "type_": { + "display": "Optional[float]", + "link_target": null, + "children": [ + { + "display": "float", + "link_target": null, + "children": [] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + }, + { + "name": "penalty_weights", + "type_": { + "display": "Optional[Mapping[int, float]]", + "link_target": null, + "children": [ + { + "display": "Mapping[int, float]", + "link_target": null, + "children": [ + { + "display": "int", + "link_target": null, + "children": [] + }, + { + "display": "float", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + }, + { + "name": "inequality_integer_slack_max_range", + "type_": { + "display": "int", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "31" + } + } + ], + "return_type": { + "display": "PreparationPolicy", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "for_qubo", + "doc": "Return a fresh policy for preparing an instance for QUBO formatting.\n\n``uniform_penalty_weight`` and ``penalty_weights`` override the default\nuniform penalty weight of 1.0 and are mutually exclusive. The keyed form\nmust cover exactly the active regular constraints at the penalty phase.\n``inequality_integer_slack_max_range`` defaults to 31 and configures both\nthe exact Integer-slack range and the fallback slack upper bound. This\nQUBO policy also reduces powers of Binary variables before checking the\nquadratic target.\n\n# Postconditions\n\nEach call returns a fresh complete QUBO policy whose optional weights and slack range are applied exactly.\n\n>>> from ommx import (\n... BinaryPowerPreparation, FixedPenaltyPreparation,\n... IntegerEncodingPreparation, IntegerSlackPreparation,\n... ObjectivePreparation, PreparationPolicy, Sense,\n... SpecialConstraintKind, SpecialConstraintPreparation,\n... )\n>>> expected_special = SpecialConstraintPreparation.lower_special_constraints(\n... kinds={\n... SpecialConstraintKind.Indicator,\n... SpecialConstraintKind.OneHot,\n... SpecialConstraintKind.Sos1,\n... }\n... )\n>>> expected_penalty = FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0)\n>>> first = PreparationPolicy.for_qubo(inequality_integer_slack_max_range=17)\n>>> second = PreparationPolicy.for_qubo(inequality_integer_slack_max_range=17)\n>>> assert first is not second\n>>> assert first.special_constraints == expected_special\n>>> assert first.objective == ObjectivePreparation(target=Sense.Minimize)\n>>> assert first.integer_slack == IntegerSlackPreparation(max_integer_range=17, slack_upper_bound=17)\n>>> assert first.integer_encoding == IntegerEncodingPreparation.log_encode_all_used_integers()\n>>> assert first.fixed_penalty == expected_penalty\n>>> assert first.binary_power_reduction == BinaryPowerPreparation()\n>>> first.fixed_penalty = None\n>>> first.binary_power_reduction = None\n>>> assert second.fixed_penalty == expected_penalty\n>>> assert second.binary_power_reduction == BinaryPowerPreparation()\n>>> keyed = PreparationPolicy.for_qubo(penalty_weights={3: 2.0})\n>>> assert keyed.fixed_penalty == FixedPenaltyPreparation.penalty_method_with_fixed_weights(weights={3: 2.0})\n\n# Errors\n\nSupplying uniform and keyed penalty weights together raises ``ValueError``.\n\n>>> try:\n... PreparationPolicy.for_qubo(uniform_penalty_weight=1.0, penalty_weights={3: 2.0})\n... except ValueError as error:\n... assert \"Both uniform_penalty_weight\" in str(error)\n... else:\n... raise AssertionError(\"mutually exclusive penalty options were accepted\")", + "signatures": [ + { + "parameters": [ + { + "name": "uniform_penalty_weight", + "type_": { + "display": "Optional[float]", + "link_target": null, + "children": [ + { + "display": "float", + "link_target": null, + "children": [] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + }, + { + "name": "penalty_weights", + "type_": { + "display": "Optional[Mapping[int, float]]", + "link_target": null, + "children": [ + { + "display": "Mapping[int, float]", + "link_target": null, + "children": [ + { + "display": "int", + "link_target": null, + "children": [] + }, + { + "display": "float", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + }, + { + "name": "inequality_integer_slack_max_range", + "type_": { + "display": "int", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "31" + } } ], "return_type": { @@ -19427,6 +19807,22 @@ } ], "attributes": [ + { + "name": "binary_power_reduction", + "doc": "", + "type_": { + "display": "Optional[BinaryPowerPreparation]", + "link_target": null, + "children": [ + { + "display": "BinaryPowerPreparation", + "link_target": null, + "children": [] + } + ] + }, + "is_property": true + }, { "name": "fixed_penalty", "doc": "", @@ -19476,14 +19872,14 @@ "is_property": true }, { - "name": "sense", + "name": "objective", "doc": "", "type_": { - "display": "Optional[SensePreparation]", + "display": "Optional[ObjectivePreparation]", "link_target": null, "children": [ { - "display": "SensePreparation", + "display": "ObjectivePreparation", "link_target": null, "children": [] } @@ -19661,7 +20057,7 @@ { "kind": "Class", "name": "Quadratic", - "doc": "Quadratic function of decision variables.\n\nA quadratic function has the form: $c_0 + \\sum_i c_i x_i + \\sum_{ij} q_{ij} x_i x_j$\nwhere $x_i$ are decision variables and $c_i$, $q_{ij}$ are coefficients.\n\n# Examples\n\nCreate via DecisionVariable multiplication:\n\n```python\n>>> x = DecisionVariable.integer(1)\n>>> y = DecisionVariable.integer(2)\n>>> q = x * y + 2*x + 3*y + 1\n```\n\nNote that `==`, `<=`, `>=` create Constraint objects:\n\n```python\n>>> constraint = q <= 10 # Returns Constraint\n```", + "doc": "Quadratic function of decision variables.\n\nA quadratic function has the form: $c_0 + \\sum_i c_i x_i + \\sum_{ij} q_{ij} x_i x_j$\nwhere $x_i$ are decision variables and $c_i$, $q_{ij}$ are coefficients.\n\n# Examples\n\nCreate via DecisionVariable multiplication:\n\n>>> x = DecisionVariable.integer(1)\n>>> y = DecisionVariable.integer(2)\n>>> q = x * y + 2*x + 3*y + 1\n\nNote that `==`, `<=`, `>=` create Constraint objects:\n\n>>> constraint = q <= 10 # Returns Constraint", "bases": [], "methods": [ { @@ -21612,7 +22008,7 @@ { "kind": "Class", "name": "SampleSet", - "doc": "The output of sampling-based optimization algorithms, e.g. simulated annealing (SA).\n\n- Similar to `Solution` rather than the raw `State` message.\n This class contains the sampled values of decision variables with the objective value, constraint violations,\n feasibility, and modeling labels/context of constraints and decision variables.\n- This class is usually created via `Instance.evaluate_samples`.\n\n# Examples\n\nLet's consider a simple optimization problem:\n\nmaximize x_1 + 2 x_2 + 3 x_3\nsubject to x_1 + x_2 + x_3 = 1\nx_1, x_2, x_3 in {0, 1}\n\n```python\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=x[0] + 2*x[1] + 3*x[2],\n... constraints=[sum(x) == 1],\n... sense=Instance.MAXIMIZE,\n... )\n```\n\nwith three samples:\n\n```python\n>>> samples = {\n... 0: {0: 1, 1: 0, 2: 0}, # x1 = 1, x2 = x3 = 0\n... 1: {0: 0, 1: 0, 2: 1}, # x3 = 1, x1 = x2 = 0\n... 2: {0: 1, 1: 1, 2: 0}, # x1 = x2 = 1, x3 = 0 (infeasible)\n... } # ^ sample ID\n```\n\nNote that this will be done by sampling-based solvers, but we do it manually here.\nWe can evaluate the samples via `Instance.evaluate_samples`:\n\n```python\n>>> sample_set = instance.evaluate_samples(samples)\n>>> sample_set.summary # doctest: +NORMALIZE_WHITESPACE\n objective feasible\nsample_id\n1 3.0 True\n0 1.0 True\n2 3.0 False\n```\n\nThe `summary` attribute shows the objective value, feasibility of each sample.\nNote that this `feasible` column represents the feasibility of the original constraints, not the relaxed constraints.\nYou can get each sample by `get` as a `Solution` format:\n\n```python\n>>> solution = sample_set.get(sample_id=0)\n>>> solution.objective\n1.0\n```\n\n`best_feasible` returns the best feasible sample, i.e. the largest objective value among feasible samples:\n\n```python\n>>> solution = sample_set.best_feasible\n>>> solution.objective\n3.0\n```\n\nOf course, the sample of smallest objective value is returned for minimization problems.", + "doc": "The output of sampling-based optimization algorithms, e.g. simulated annealing (SA).\n\n- Similar to `Solution` rather than the raw `State` message.\n This class contains the sampled values of decision variables with the objective value, constraint violations,\n feasibility, and modeling labels/context of constraints and decision variables.\n- This class is usually created via `Instance.evaluate_samples`.\n\n# Examples\n\nLet's consider a simple optimization problem:\n\nmaximize x_1 + 2 x_2 + 3 x_3\nsubject to x_1 + x_2 + x_3 = 1\nx_1, x_2, x_3 in {0, 1}\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=x[0] + 2*x[1] + 3*x[2],\n... constraints={0: sum(x) == 1},\n... sense=Sense.Maximize,\n... )\n\nwith three samples:\n\n>>> samples = {\n... 0: {0: 1, 1: 0, 2: 0}, # x1 = 1, x2 = x3 = 0\n... 1: {0: 0, 1: 0, 2: 1}, # x3 = 1, x1 = x2 = 0\n... 2: {0: 1, 1: 1, 2: 0}, # x1 = x2 = 1, x3 = 0 (infeasible)\n... } # ^ sample ID\n\nNote that this will be done by sampling-based solvers, but we do it manually here.\nWe can evaluate the samples via `Instance.evaluate_samples`:\n\n>>> sample_set = instance.evaluate_samples(samples)\n>>> sample_set.summary # doctest: +NORMALIZE_WHITESPACE\n objective feasible\nsample_id\n1 3.0 True\n0 1.0 True\n2 3.0 False\n\nThe `summary` attribute shows the objective value, feasibility of each sample.\nNote that this `feasible` column represents the feasibility of the original constraints, not the relaxed constraints.\nYou can get each sample by `get` as a `Solution` format:\n\n>>> solution = sample_set.get(sample_id=0)\n>>> solution.objective\n1.0\n\n`best_feasible` returns the best feasible sample, i.e. the largest objective value among feasible samples:\n\n>>> solution = sample_set.best_feasible\n>>> solution.objective\n3.0\n\nOf course, the sample of smallest objective value is returned for minimization problems.", "bases": [], "methods": [ { @@ -22070,7 +22466,7 @@ }, { "name": "extract_all_decision_variables", - "doc": "Extract all decision variables grouped by name for a given sample ID.\n\nReturns a mapping from variable name to a mapping from subscripts to values.\nThis is useful for extracting all variables at once in a structured format.\nVariables without names are not included in the result.\n\nRaises KeyError if the sample ID does not exist, and ValueError if the\nsame name and subscript combination is found multiple times.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}})\n>>> all_vars = sample_set.extract_all_decision_variables(0)\n>>> all_vars[\"x\"]\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}\n>>> all_vars[\"y\"]\n{(0,): 1.0, (1,): 1.0}\n```", + "doc": "Extract all decision variables grouped by name for a given sample ID.\n\nReturns a mapping from variable name to a mapping from subscripts to values.\nThis is useful for extracting all variables at once in a structured format.\nVariables without names are not included in the result.\n\nRaises KeyError if the sample ID does not exist, and ValueError if the\nsame name and subscript combination is found multiple times.\n\n# Examples\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints={},\n... sense=Sense.Maximize,\n... )\n>>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}})\n>>> all_vars = sample_set.extract_all_decision_variables(0)\n>>> all_vars[\"x\"]\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}\n>>> all_vars[\"y\"]\n{(0,): 1.0, (1,): 1.0}", "signatures": [ { "parameters": [ @@ -22829,7 +23225,7 @@ }, { "name": "decision_variable_names", - "doc": "Get all unique decision variable names in this sample set.\n\nReturns a set of all unique variable names. Variables without names are not included.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}})\n>>> sorted(sample_set.decision_variable_names)\n['x', 'y']\n```", + "doc": "Get all unique decision variable names in this sample set.\n\nReturns a set of all unique variable names. Variables without names are not included.\n\n# Examples\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints={},\n... sense=Sense.Maximize,\n... )\n>>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}})\n>>> sorted(sample_set.decision_variable_names)\n['x', 'y']", "type_": { "display": "set[str]", "link_target": null, @@ -23872,58 +24268,6 @@ ], "deprecated": null }, - { - "kind": "Class", - "name": "SensePreparation", - "doc": "", - "bases": [], - "methods": [ - { - "name": "__eq__", - "doc": "", - "signatures": [ - { - "parameters": [ - { - "name": "other", - "type_": { - "display": "object", - "link_target": null, - "children": [] - }, - "default": null - } - ], - "return_type": { - "display": "bool", - "link_target": null, - "children": [] - } - } - ], - "is_async": false, - "deprecated": null - }, - { - "name": "as_minimization_problem", - "doc": "", - "signatures": [ - { - "parameters": [], - "return_type": { - "display": "SensePreparation", - "link_target": null, - "children": [] - } - } - ], - "is_async": false, - "deprecated": null - } - ], - "attributes": [], - "deprecated": null - }, { "kind": "Class", "name": "Solution", @@ -24385,7 +24729,7 @@ }, { "name": "extract_all_decision_variables", - "doc": "Extract all decision variables grouped by name.\n\nReturns a mapping from variable name to a mapping from subscripts to values.\nThis is useful for extracting all variables at once in a structured format.\nVariables without names are not included in the result.\n\nRaises ValueError if the same name and subscript combination is found\nmultiple times.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> solution = instance.evaluate({i: 1 for i in range(5)})\n>>> all_vars = solution.extract_all_decision_variables()\n>>> all_vars[\"x\"]\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}\n>>> all_vars[\"y\"]\n{(0,): 1.0, (1,): 1.0}\n```", + "doc": "Extract all decision variables grouped by name.\n\nReturns a mapping from variable name to a mapping from subscripts to values.\nThis is useful for extracting all variables at once in a structured format.\nVariables without names are not included in the result.\n\nRaises ValueError if the same name and subscript combination is found\nmultiple times.\n\n# Examples\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints={},\n... sense=Sense.Maximize,\n... )\n>>> solution = instance.evaluate({i: 1 for i in range(5)})\n>>> all_vars = solution.extract_all_decision_variables()\n>>> all_vars[\"x\"]\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}\n>>> all_vars[\"y\"]\n{(0,): 1.0, (1,): 1.0}", "signatures": [ { "parameters": [], @@ -24417,7 +24761,7 @@ }, { "name": "extract_constraints", - "doc": "Extract the values of constraints based on the `name` with `subscripts` key.\n\nRaises KeyError if no constraint has the requested name. Raises\nValueError if a matching constraint has parameters or if the same\nsubscript is found more than once.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> c0 = (x[0] + x[1] == 1).set_name(\"c\").add_subscripts([0])\n>>> c1 = (x[1] + x[2] == 1).set_name(\"c\").add_subscripts([1])\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[c0, c1],\n... sense=Instance.MAXIMIZE,\n... )\n>>> solution = instance.evaluate({0: 1, 1: 0, 2: 1})\n>>> solution.extract_constraints(\"c\")\n{(0,): 0.0, (1,): 0.0}\n```", + "doc": "Extract the values of constraints based on the `name` with `subscripts` key.\n\nRaises KeyError if no constraint has the requested name. Raises\nValueError if a matching constraint has parameters or if the same\nsubscript is found more than once.\n\n# Examples\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> c0 = (x[0] + x[1] == 1).set_name(\"c\").add_subscripts([0])\n>>> c1 = (x[1] + x[2] == 1).set_name(\"c\").add_subscripts([1])\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={0: c0, 1: c1},\n... sense=Sense.Maximize,\n... )\n>>> solution = instance.evaluate({0: 1, 1: 0, 2: 1})\n>>> solution.extract_constraints(\"c\")\n{(0,): 0.0, (1,): 0.0}", "signatures": [ { "parameters": [ @@ -24443,7 +24787,7 @@ }, { "name": "extract_decision_variables", - "doc": "Extract the values of decision variables based on the `name` with `subscripts` key.\n\nRaises KeyError if no decision variable has the requested name, and\nValueError if the same subscript is found more than once.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[sum(x) == 1],\n... sense=Instance.MAXIMIZE,\n... )\n>>> solution = instance.evaluate({i: 1 for i in range(3)})\n>>> solution.extract_decision_variables(\"x\")\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}\n```", + "doc": "Extract the values of decision variables based on the `name` with `subscripts` key.\n\nRaises KeyError if no decision variable has the requested name, and\nValueError if the same subscript is found more than once.\n\n# Examples\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints={0: sum(x) == 1},\n... sense=Sense.Maximize,\n... )\n>>> solution = instance.evaluate({i: 1 for i in range(3)})\n>>> solution.extract_decision_variables(\"x\")\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}", "signatures": [ { "parameters": [ @@ -25083,7 +25427,7 @@ }, { "name": "decision_variable_names", - "doc": "Get all unique decision variable names in this solution.\n\nReturns a set of all unique variable names. Variables without names are not included.\n\n# Examples\n\n```python\n>>> from ommx import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> solution = instance.evaluate({i: 1 for i in range(5)})\n>>> sorted(solution.decision_variable_names)\n['x', 'y']\n```", + "doc": "Get all unique decision variable names in this solution.\n\nReturns a set of all unique variable names. Variables without names are not included.\n\n# Examples\n\n>>> from ommx import DecisionVariable, Instance, Sense\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints={},\n... sense=Sense.Maximize,\n... )\n>>> solution = instance.evaluate({i: 1 for i in range(5)})\n>>> sorted(solution.decision_variable_names)\n['x', 'y']", "type_": { "display": "set[str]", "link_target": null, @@ -26832,7 +27176,7 @@ { "kind": "Class", "name": "Artifact", - "doc": "Reader for OMMX Artifacts.\n\nAn artifact is an OCI container image that stores OMMX data\n(instances, solutions, sample sets, etc.) as layers.\n\n```python\n>>> artifact = Artifact.load(\"ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f\")\n>>> print(artifact.image_name)\nghcr.io/jij-inc/ommx/random_lp_instance:4303c7f\n\n```", + "doc": "Reader for OMMX Artifacts.\n\nAn artifact is an OCI container image that stores OMMX data\n(instances, solutions, sample sets, etc.) as layers.\n\n>>> artifact = Artifact.load(\"ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f\")\n>>> print(artifact.image_name)\nghcr.io/jij-inc/ommx/random_lp_instance:4303c7f", "bases": [], "methods": [ { @@ -27183,7 +27527,7 @@ }, { "name": "import_archive", - "doc": "Import an artifact from a `.ommx` OCI archive file (or an OCI\nImage Layout directory) into the user's v3 SQLite Local Registry,\nand return a handle to the imported registry entry.\n\n**Side effect (intentional)**: archive / directory contents are\npermanently written into the SQLite Local Registry under the\ndefault root (`$XDG_DATA_HOME/ommx/` on Linux,\n`$HOME/Library/Application Support/org.ommx.ommx/` on macOS, or\n`$OMMX_LOCAL_REGISTRY_ROOT` when set). Subsequent\n`Artifact.load(image_name)` calls resolve from SQLite without\nre-importing.\n\nFor a side-effect-free read that just surfaces the manifest /\nlayer descriptors without writing into the registry, use\n{meth}`Artifact.inspect_archive` instead.\n\nIf the input lacks an `org.opencontainers.image.ref.name`\nannotation, v3 synthesizes an anonymous Local Registry ref and\nimports the content under that name. This keeps v2 unnamed\narchives importable while still making the imported artifact\naddressable in SQLite.\n\n```python\n>>> artifact = Artifact.import_archive(\"data/random_lp_instance.ommx\")\n>>> print(artifact.image_name)\nghcr.io/jij-inc/ommx/random_lp_instance:...\n\n```", + "doc": "Import an artifact from a `.ommx` OCI archive file (or an OCI\nImage Layout directory) into the user's v3 SQLite Local Registry,\nand return a handle to the imported registry entry.\n\n**Side effect (intentional)**: archive / directory contents are\npermanently written into the SQLite Local Registry under the\ndefault root (`$XDG_DATA_HOME/ommx/` on Linux,\n`$HOME/Library/Application Support/org.ommx.ommx/` on macOS, or\n`$OMMX_LOCAL_REGISTRY_ROOT` when set). Subsequent\n`Artifact.load(image_name)` calls resolve from SQLite without\nre-importing.\n\nFor a side-effect-free read that just surfaces the manifest /\nlayer descriptors without writing into the registry, use\n{meth}`Artifact.inspect_archive` instead.\n\nIf the input lacks an `org.opencontainers.image.ref.name`\nannotation, v3 synthesizes an anonymous Local Registry ref and\nimports the content under that name. This keeps v2 unnamed\narchives importable while still making the imported artifact\naddressable in SQLite.\n\n>>> artifact = Artifact.import_archive(\"data/random_lp_instance.ommx\")\n>>> print(artifact.image_name)\nghcr.io/jij-inc/ommx/random_lp_instance:...", "signatures": [ { "parameters": [ @@ -27230,7 +27574,7 @@ }, { "name": "inspect_archive", - "doc": "Read a `.ommx` OCI archive's manifest and layer descriptors\nwithout importing it into the SQLite Local Registry. Useful\nwhen you want to inspect an archive's contents (e.g. iterate\nlayer media types or check the artifact type) without\ntriggering a registry write — the analogue of\n`ommx inspect ` from the CLI.\n\nFor full registry import (so the artifact is reachable by\n`Artifact.load(image_name)` later), use\n{meth}`Artifact.import_archive`.\n\n```python\n>>> manifest = Artifact.inspect_archive(\"data/random_lp_instance.ommx\")\n>>> for layer in manifest.layers:\n... print(layer.media_type)\napplication/org.ommx.v1.instance\n\n```", + "doc": "Read a `.ommx` OCI archive's manifest and layer descriptors\nwithout importing it into the SQLite Local Registry. Useful\nwhen you want to inspect an archive's contents (e.g. iterate\nlayer media types or check the artifact type) without\ntriggering a registry write — the analogue of\n`ommx inspect ` from the CLI.\n\nFor full registry import (so the artifact is reachable by\n`Artifact.load(image_name)` later), use\n{meth}`Artifact.import_archive`.\n\n>>> manifest = Artifact.inspect_archive(\"data/random_lp_instance.ommx\")\n>>> for layer in manifest.layers:\n... print(layer.media_type)\napplication/org.ommx.v1.instance", "signatures": [ { "parameters": [ @@ -27277,7 +27621,7 @@ }, { "name": "load", - "doc": "Load an artifact stored as a container image in local or remote registry.\n\nIf the image is not found in local registry, it will try to pull from remote registry.\n\n```python\n>>> artifact = Artifact.load(\"ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f\")\n>>> print(artifact.image_name)\nghcr.io/jij-inc/ommx/random_lp_instance:4303c7f\n\n```\n\nRaises {class}`~ommx.artifact.RemoteArtifactNotFoundError` when the\nexact remote reference does not exist. Other remote access failures\nraise subclasses of {class}`~ommx.artifact.RemoteArtifactError`.\nAn invalid image reference raises {class}`ValueError` before any remote\naccess is attempted.", + "doc": "Load an artifact stored as a container image in local or remote registry.\n\nIf the image is not found in local registry, it will try to pull from remote registry.\n\n>>> artifact = Artifact.load(\"ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f\")\n>>> print(artifact.image_name)\nghcr.io/jij-inc/ommx/random_lp_instance:4303c7f\n\n\nRaises {class}`~ommx.artifact.RemoteArtifactNotFoundError` when the\nexact remote reference does not exist. Other remote access failures\nraise subclasses of {class}`~ommx.artifact.RemoteArtifactError`.\nAn invalid image reference raises {class}`ValueError` before any remote\naccess is attempted.", "signatures": [ { "parameters": [ @@ -27533,7 +27877,7 @@ { "kind": "Class", "name": "ArtifactDraft", - "doc": "Mutable draft for OMMX Artifacts.\n\n```python\n>>> draft = ArtifactDraft.temp()\n>>> artifact = draft.commit()\n>>> print(artifact.image_name)\nttl.sh/...-...-...-...-...:1h\n\n```", + "doc": "Mutable draft for OMMX Artifacts.\n\n>>> draft = ArtifactDraft.temp()\n>>> artifact = draft.commit()\n>>> print(artifact.image_name)\nttl.sh/...-...-...-...-...:1h", "bases": [], "methods": [ { @@ -27573,7 +27917,7 @@ }, { "name": "add_dataframe", - "doc": "Add a pandas DataFrame to the artifact with parquet format.\n\n```python\n>>> import pandas as pd\n>>> df = pd.DataFrame({\"a\": [1, 2], \"b\": [3, 4]})\n>>> draft = ArtifactDraft.temp()\n>>> _desc = draft.add_dataframe(df, title=\"test_dataframe\")\n>>> artifact = draft.commit()\n>>> layer = artifact.layers[0]\n>>> print(layer.media_type)\napplication/vnd.apache.parquet\n\n```", + "doc": "Add a pandas DataFrame to the artifact with parquet format.\n\n>>> import pandas as pd\n>>> df = pd.DataFrame({\"a\": [1, 2], \"b\": [3, 4]})\n>>> draft = ArtifactDraft.temp()\n>>> _desc = draft.add_dataframe(df, title=\"test_dataframe\")\n>>> artifact = draft.commit()\n>>> layer = artifact.layers[0]\n>>> print(layer.media_type)\napplication/vnd.apache.parquet", "signatures": [ { "parameters": [ @@ -27625,7 +27969,7 @@ }, { "name": "add_instance", - "doc": "Add an {class}`~ommx.Instance` to the artifact with annotations.\n\n```python\n>>> from ommx import Instance\n>>> instance = Instance.minimize()\n>>> instance.title = \"test instance\"\n>>> draft = ArtifactDraft.temp()\n>>> desc = draft.add_instance(instance)\n>>> print(desc.annotations['org.ommx.v1.instance.title'])\ntest instance\n\n```", + "doc": "Add an {class}`~ommx.Instance` to the artifact with annotations.\n\n>>> from ommx import Instance\n>>> instance = Instance.minimize()\n>>> instance.title = \"test instance\"\n>>> draft = ArtifactDraft.temp()\n>>> desc = draft.add_instance(instance)\n>>> print(desc.annotations['org.ommx.v1.instance.title'])\ntest instance", "signatures": [ { "parameters": [ @@ -27656,7 +28000,7 @@ }, { "name": "add_json", - "doc": "Add a JSON object to the artifact.\n\n```python\n>>> obj = {\"a\": 1, \"b\": 2}\n>>> draft = ArtifactDraft.temp()\n>>> _desc = draft.add_json(obj, title=\"test_json\")\n>>> artifact = draft.commit()\n>>> layer = artifact.layers[0]\n>>> print(layer.media_type)\napplication/json\n\n```", + "doc": "Add a JSON object to the artifact.\n\n>>> obj = {\"a\": 1, \"b\": 2}\n>>> draft = ArtifactDraft.temp()\n>>> _desc = draft.add_json(obj, title=\"test_json\")\n>>> artifact = draft.commit()\n>>> layer = artifact.layers[0]\n>>> print(layer.media_type)\napplication/json", "signatures": [ { "parameters": [ @@ -27771,7 +28115,7 @@ }, { "name": "add_ndarray", - "doc": "Add a numpy ndarray to the artifact with npy format.\n\n```python\n>>> import numpy as np\n>>> array = np.array([1, 2, 3])\n>>> draft = ArtifactDraft.temp()\n>>> _desc = draft.add_ndarray(array, title=\"test_array\")\n>>> artifact = draft.commit()\n>>> layer = artifact.layers[0]\n>>> print(layer.media_type)\napplication/vnd.numpy\n>>> print(layer.annotations)\n{'org.ommx.user.title': 'test_array'}\n\n```", + "doc": "Add a numpy ndarray to the artifact with npy format.\n\n>>> import numpy as np\n>>> array = np.array([1, 2, 3])\n>>> draft = ArtifactDraft.temp()\n>>> _desc = draft.add_ndarray(array, title=\"test_array\")\n>>> artifact = draft.commit()\n>>> layer = artifact.layers[0]\n>>> print(layer.media_type)\napplication/vnd.numpy\n>>> print(layer.annotations)\n{'org.ommx.user.title': 'test_array'}", "signatures": [ { "parameters": [ @@ -27995,7 +28339,7 @@ }, { "name": "new", - "doc": "Create a new artifact draft with an explicit image name. The\nartifact is published into the user's persistent SQLite Local\nRegistry on `commit()`; call {meth}`Artifact.save(path)` on the\nreturned handle if you also want a `.ommx` archive file for\nsharing.\n\n```python\n>>> from ommx.testing import SingleFeasibleLPGenerator, DataType\n>>> generator = SingleFeasibleLPGenerator(3, DataType.INT)\n>>> instance = generator.get_v1_instance()\n>>> import uuid\n>>> image_name = f\"ghcr.io/jij-inc/ommx/single_feasible_lp:{uuid.uuid4()}\"\n>>> draft = ArtifactDraft.new(image_name)\n>>> _desc = draft.add_instance(instance)\n>>> artifact = draft.commit()\n>>> print(artifact.image_name)\nghcr.io/jij-inc/ommx/single_feasible_lp:...\n\n```\n\nRaises {class}`ValueError` when `image_name` is not a valid OCI image\nreference. Registry and storage failures raise {class}`RuntimeError`.", + "doc": "Create a new artifact draft with an explicit image name. The\nartifact is published into the user's persistent SQLite Local\nRegistry on `commit()`; call {meth}`Artifact.save(path)` on the\nreturned handle if you also want a `.ommx` archive file for\nsharing.\n\n>>> from ommx.testing import SingleFeasibleLPGenerator, DataType\n>>> generator = SingleFeasibleLPGenerator(3, DataType.INT)\n>>> instance = generator.get_v1_instance()\n>>> import uuid\n>>> image_name = f\"ghcr.io/jij-inc/ommx/single_feasible_lp:{uuid.uuid4()}\"\n>>> draft = ArtifactDraft.new(image_name)\n>>> _desc = draft.add_instance(instance)\n>>> artifact = draft.commit()\n>>> print(artifact.image_name)\nghcr.io/jij-inc/ommx/single_feasible_lp:...\n\n\nRaises {class}`ValueError` when `image_name` is not a valid OCI image\nreference. Registry and storage failures raise {class}`RuntimeError`.", "signatures": [ { "parameters": [ @@ -28026,7 +28370,7 @@ }, { "name": "new_anonymous", - "doc": "Create a new artifact draft without inventing an image name.\n\nUX shortcut: a synthetic image name of the form\n`.ommx.local/anonymous:-`\nis generated when the draft is created and used as the SQLite Local\nRegistry key. v3 stores every artifact in the registry, so\nanonymous artifacts still need a key — the registry-id prefix\n(a random 8-hex truncation of a UUID generated once per\n`LocalRegistry` and persisted in its SQLite metadata)\nidentifies which registry produced the artifact (useful when\narchives are shared), the local-time timestamp lets you\nidentify entries by when they were created, and the 12-hex\n(48-bit) random nonce keeps concurrent anonymous drafts\n(MINTO-style scripts emitting many artifacts per second)\ncollision-free regardless of clock resolution. Use\n`Artifact.image_name` to read the synthesized name back. The\n`.local` mDNS TLD prevents an accidental push from leaking to\na real remote registry. Use `ommx prune-anonymous`\nto clean accumulated entries.\n\nThe timestamp is the **caller's local time** with no timezone\nmarker. If an anonymous archive is shared with someone in a\ndifferent timezone, the recipient will see the same digits but\ninterpret them as their own local time — the time component\nloses absolute meaning across machines. Anonymous artifacts\nare not intended for cross-timezone sharing; pick an explicit\nname via `ArtifactDraft.new(...)` if absolute time matters.\n\nCall {meth}`Artifact.save(path)` on the returned handle to also\nwrite a `.ommx` archive file for sharing.\n\n```python\n>>> from ommx.testing import SingleFeasibleLPGenerator, DataType\n>>> generator = SingleFeasibleLPGenerator(3, DataType.INT)\n>>> instance = generator.get_v1_instance()\n>>> draft = ArtifactDraft.new_anonymous()\n>>> _desc = draft.add_instance(instance)\n>>> artifact = draft.commit()\n>>> assert \".ommx.local/anonymous:\" in artifact.image_name\n\n```", + "doc": "Create a new artifact draft without inventing an image name.\n\nUX shortcut: a synthetic image name of the form\n`.ommx.local/anonymous:-`\nis generated when the draft is created and used as the SQLite Local\nRegistry key. v3 stores every artifact in the registry, so\nanonymous artifacts still need a key — the registry-id prefix\n(a random 8-hex truncation of a UUID generated once per\n`LocalRegistry` and persisted in its SQLite metadata)\nidentifies which registry produced the artifact (useful when\narchives are shared), the local-time timestamp lets you\nidentify entries by when they were created, and the 12-hex\n(48-bit) random nonce keeps concurrent anonymous drafts\n(MINTO-style scripts emitting many artifacts per second)\ncollision-free regardless of clock resolution. Use\n`Artifact.image_name` to read the synthesized name back. The\n`.local` mDNS TLD prevents an accidental push from leaking to\na real remote registry. Use `ommx prune-anonymous`\nto clean accumulated entries.\n\nThe timestamp is the **caller's local time** with no timezone\nmarker. If an anonymous archive is shared with someone in a\ndifferent timezone, the recipient will see the same digits but\ninterpret them as their own local time — the time component\nloses absolute meaning across machines. Anonymous artifacts\nare not intended for cross-timezone sharing; pick an explicit\nname via `ArtifactDraft.new(...)` if absolute time matters.\n\nCall {meth}`Artifact.save(path)` on the returned handle to also\nwrite a `.ommx` archive file for sharing.\n\n>>> from ommx.testing import SingleFeasibleLPGenerator, DataType\n>>> generator = SingleFeasibleLPGenerator(3, DataType.INT)\n>>> instance = generator.get_v1_instance()\n>>> draft = ArtifactDraft.new_anonymous()\n>>> _desc = draft.add_instance(instance)\n>>> artifact = draft.commit()\n>>> assert \".ommx.local/anonymous:\" in artifact.image_name", "signatures": [ { "parameters": [], @@ -28047,7 +28391,7 @@ }, { "name": "temp", - "doc": "Create a new artifact draft under a random `ttl.sh` image name.\nInsecure; for tests only. `ttl.sh` is a public registry that\nexpires images after one hour.\n\n```python\n>>> draft = ArtifactDraft.temp()\n>>> artifact = draft.commit()\n>>> print(artifact.image_name)\nttl.sh/...-...-...-...-...:1h\n\n```", + "doc": "Create a new artifact draft under a random `ttl.sh` image name.\nInsecure; for tests only. `ttl.sh` is a public registry that\nexpires images after one hour.\n\n>>> draft = ArtifactDraft.temp()\n>>> artifact = draft.commit()\n>>> print(artifact.image_name)\nttl.sh/...-...-...-...-...:1h", "signatures": [ { "parameters": [], @@ -29136,7 +29480,7 @@ { "kind": "Function", "name": "gc", - "doc": "Report or delete Local Registry blobs unreachable from SQLite refs.\n\nThis is the Python SDK equivalent of `ommx gc`. It is a dry-run by\ndefault. Pass `delete=True` to unlink orphan candidates. The `grace_period`\nstring accepts the same `s`, `m`, `h`, and `d` suffixes as the CLI.\nAn invalid duration raises {class}`ValueError`; registry and storage failures\nraise {class}`RuntimeError`.\n\n```python\n>>> from ommx.artifact import gc\n>>> report = gc()\n>>> report.delete_applied\nFalse\n\n```", + "doc": "Report or delete Local Registry blobs unreachable from SQLite refs.\n\nThis is the Python SDK equivalent of `ommx gc`. It is a dry-run by\ndefault. Pass `delete=True` to unlink orphan candidates. The `grace_period`\nstring accepts the same `s`, `m`, `h`, and `d` suffixes as the CLI.\nAn invalid duration raises {class}`ValueError`; registry and storage failures\nraise {class}`RuntimeError`.\n\n>>> from ommx.artifact import gc\n>>> report = gc()\n>>> report.delete_applied\nFalse", "signatures": [ { "parameters": [ @@ -29362,7 +29706,7 @@ { "kind": "Function", "name": "prune_anonymous", - "doc": "Report or delete anonymous Artifact and Experiment refs in the Local Registry.\n\nThis is the Python SDK equivalent of `ommx prune-anonymous`.\nIt only removes SQLite refs when `delete=True`; manifest and payload blobs\nare left for {func}`gc` to reclaim if they become unreachable.\nAnonymous Experiment refs are included only when `experiments=True`.\n`older_than` accepts the same `s`, `m`, `h`, and `d` suffixes as the CLI.\nAn invalid duration raises {class}`ValueError`; registry and storage failures\nraise {class}`RuntimeError`.\n\n```python\n>>> from ommx.artifact import prune_anonymous\n>>> report = prune_anonymous()\n>>> report.delete_applied\nFalse\n\n```", + "doc": "Report or delete anonymous Artifact and Experiment refs in the Local Registry.\n\nThis is the Python SDK equivalent of `ommx prune-anonymous`.\nIt only removes SQLite refs when `delete=True`; manifest and payload blobs\nare left for {func}`gc` to reclaim if they become unreachable.\nAnonymous Experiment refs are included only when `experiments=True`.\n`older_than` accepts the same `s`, `m`, `h`, and `d` suffixes as the CLI.\nAn invalid duration raises {class}`ValueError`; registry and storage failures\nraise {class}`RuntimeError`.\n\n>>> from ommx.artifact import prune_anonymous\n>>> report = prune_anonymous()\n>>> report.delete_applied\nFalse", "signatures": [ { "parameters": [ @@ -33672,6 +34016,7 @@ "ommx.AttachedIndicatorConstraint": "ommx", "ommx.AttachedOneHotConstraint": "ommx", "ommx.AttachedSos1Constraint": "ommx", + "ommx.BinaryPowerPreparation": "ommx", "ommx.Bound": "ommx", "ommx.Constraint": "ommx", "ommx.DecisionVariable": "ommx", @@ -33698,6 +34043,7 @@ "ommx.Linear": "ommx", "ommx.LogEncodingError": "ommx", "ommx.NamedFunction": "ommx", + "ommx.ObjectivePreparation": "ommx", "ommx.OneHotConstraint": "ommx", "ommx.Optimality": "ommx", "ommx.Parameter": "ommx", @@ -33720,7 +34066,6 @@ "ommx.SampledNamedFunction": "ommx", "ommx.Samples": "ommx", "ommx.Sense": "ommx", - "ommx.SensePreparation": "ommx", "ommx.Solution": "ommx", "ommx.Sos1Constraint": "ommx", "ommx.SpecialConstraintKind": "ommx", diff --git a/docs/api/ommx.rst b/docs/api/ommx.rst index 1081f11ad..f2a412931 100644 --- a/docs/api/ommx.rst +++ b/docs/api/ommx.rst @@ -34,9 +34,10 @@ ommx _items/ommx.InstanceClassClauseReport _items/ommx.InstanceClassMembershipReport _items/ommx.SpecialConstraintPreparation - _items/ommx.SensePreparation + _items/ommx.ObjectivePreparation _items/ommx.IntegerSlackPreparation _items/ommx.IntegerEncodingPreparation + _items/ommx.BinaryPowerPreparation _items/ommx.FixedPenaltyPreparation _items/ommx.PreparationPolicy _items/ommx.Constraint diff --git a/docs/en/migration/python_sdk_v2_to_v3.md b/docs/en/migration/python_sdk_v2_to_v3.md index 9549a2b52..d36b8dd54 100644 --- a/docs/en/migration/python_sdk_v2_to_v3.md +++ b/docs/en/migration/python_sdk_v2_to_v3.md @@ -230,7 +230,7 @@ The dict shape itself landed in 3.0.0a2 with snapshot `Constraint` values. In 3. `SampleSet.constraints` / `.decision_variables` / `.named_functions` remain `list`. -## 5. Renames and signature changes +## 5. Renames, signature changes, and behavior changes ### 5.1 `write_mps` → `save_mps` (`3.0.0a1`, [#775](https://github.com/Jij-Inc/ommx/pull/775)) @@ -299,6 +299,29 @@ from ommx import Linear Linear(terms={int(j): float(c) for j, c in enumerate(row)}, constant=float(-b)) ``` +### 5.6 `to_qubo()` / `to_hubo()` preserve the input objective in evaluated output (`3.0.0`, [#1167](https://github.com/Jij-Inc/ommx/pull/1167)) + +The driver methods remain available and still mutate their input. In v3, the +mutated `Instance` keeps the minimization energy sent to the QUBO or HUBO +solver as its active objective, while `evaluate()` and `evaluate_samples()` +retain the objective semantics that the instance exposed before conversion. +Python SDK v2 instead restored the active sense and evaluated the final +penalized energy, mixing solver input with user-facing output. + +Code that reads `Instance.objective` therefore sees the solver energy; code +that consumes `Solution` or `SampleSet` sees the preserved input objective. +The executable postconditions are documented on {meth}`~ommx.Instance.to_qubo`, +{meth}`~ommx.Instance.to_hubo`, {meth}`~ommx.Instance.evaluate`, and +{meth}`~ommx.Instance.evaluate_samples`. + +The same pipeline can be run explicitly with +{meth}`~ommx.Instance.prepare`, {meth}`~ommx.Instance.as_qubo_format`, or +{meth}`~ommx.Instance.as_hubo_format`. The matching target classes and editable +policies are provided by {meth}`~ommx.InstanceClass.qubo`, +{meth}`~ommx.InstanceClass.hubo`, +{meth}`~ommx.PreparationPolicy.for_qubo`, and +{meth}`~ommx.PreparationPolicy.for_hubo`. + ## 6. Return-type changes ### 6.1 `Constraint.name` / `Constraint.description` are `Optional[str]` (`3.0.0a1`, [#770](https://github.com/Jij-Inc/ommx/pull/770), [#771](https://github.com/Jij-Inc/ommx/pull/771)) @@ -409,8 +432,8 @@ implicitly. In v3, the caller owns those choices. Start with the adapter's fresh recommended `PreparationPolicy`, edit application-specific fields, and apply it in place with `Instance.prepare()` before calling the strict adapter API. -The OpenJij recommendation enables special-constraint lowering, minimization -sense normalization, Integer slack, and used-Integer log encoding. Integer +The OpenJij recommendation enables special-constraint lowering, active-objective +conversion to minimization, Integer slack, and used-Integer log encoding. Integer slack first attempts exact equality conversion with range 32 and, when that exact operation is unavailable, permits inequality-preserving slack with upper bound 32. Set `slack_upper_bound=None` on a replacement diff --git a/docs/en/release_note/ommx-3.0.md b/docs/en/release_note/ommx-3.0.md index 693dde9c4..29b8598ea 100644 --- a/docs/en/release_note/ommx-3.0.md +++ b/docs/en/release_note/ommx-3.0.md @@ -8,6 +8,48 @@ Python SDK 3.0.0 contains breaking API changes. A migration guide is available i Changes merged after the most recent release will be appended here as they land, and promoted to a new version section when the next release is cut. +### ⚠ Preserve input objectives across solver Preparation ([#1167](https://github.com/Jij-Inc/ommx/pull/1167)) + +`to_qubo()` and `to_hubo()` now leave the active `Instance` as the minimization +energy used by a solver, while `Solution` and `SampleSet` retain the objective +semantics exposed by the input instance: + +```python +from ommx import DecisionVariable, Instance, Sense + +x = DecisionVariable.binary(0) +instance = Instance.from_components( + sense=Sense.Maximize, + objective=x, + decision_variables=[x], + constraints={0: x == 1}, +) + +instance.to_qubo(uniform_penalty_weight=2.0) +state = {0: 0.0} + +assert instance.sense == Sense.Minimize +assert instance.objective.evaluate(state) == 2.0 +assert instance.evaluate(state).sense == Sense.Maximize +assert instance.evaluate(state).objective == 0.0 +assert instance.evaluate_samples({0: state}).sense == Sense.Maximize +assert instance.evaluate_samples({0: state}).objectives[0] == 0.0 +``` + +This is a breaking correction from the latest stable Python SDK, whose drivers +restored the active sense and used +the penalized solver energy as the evaluated objective. The returned QUBO/HUBO +coefficients keep the same meaning. + +Penalty rewrites also map solver-reported optimality conservatively: when an +active-formulation proof does not transport, evaluated output remains +`Optimality.Unspecified`. Executable postconditions are documented on +{meth}`~ommx.Instance.to_qubo`, {meth}`~ommx.Instance.to_hubo`, +{meth}`~ommx.Instance.evaluate`, and +{meth}`~ommx.Instance.evaluate_samples`. See the +[Python SDK v2 to v3 Migration Guide](../migration/python_sdk_v2_to_v3.md) for +the explicit Preparation workflow. + ### ⚠ Adapter applicability is defined only by `INPUT_CLASS` ([#1163](https://github.com/Jij-Inc/ommx/pull/1163)) `SolverAdapter.check_applicability()` and `require_applicable()` now use diff --git a/docs/en/user_guide/sample_set.md b/docs/en/user_guide/sample_set.md index e32727323..2aa647841 100644 --- a/docs/en/user_guide/sample_set.md +++ b/docs/en/user_guide/sample_set.md @@ -80,7 +80,7 @@ sample_set = instance.evaluate_samples(samples) sample_set.summary ``` -The `summary` attribute displays each sample's objective value and feasibility in a DataFrame format. For example, the sample with `sample_id=2` is infeasible and shows `feasible=False`. The table is sorted with feasible samples appearing first, and within them, those with better bjective values (depending on whether `Instance.sense` is maximization or minimization) appear at the top. +The `summary` attribute displays each sample's objective value and feasibility in a DataFrame format. For example, the sample with `sample_id=2` is infeasible and shows `feasible=False`. The table is sorted with feasible samples appearing first, and within them, those with better objective values according to `SampleSet.sense` appear at the top. ```{note} For clarity, we explicitly pass `ommx.Samples` created by `to_samples` to `evaluate_samples`, but you can omit it because `to_samples` would be called automatically. diff --git a/docs/ja/migration/python_sdk_v2_to_v3.md b/docs/ja/migration/python_sdk_v2_to_v3.md index 5e2572915..8920ab116 100644 --- a/docs/ja/migration/python_sdk_v2_to_v3.md +++ b/docs/ja/migration/python_sdk_v2_to_v3.md @@ -175,7 +175,7 @@ for cid, c in instance.constraints.items(): `Instance` / `ParametricInstance` の制約 dict は、v3 final では `AttachedX` handle を返します。`Solution.constraints` は評価結果の snapshot なので `EvaluatedConstraint` のままです。`SampleSet.constraints` / `.decision_variables` / `.named_functions` は `list` のままです。 -## 5. rename と signature 変更 +## 5. rename、signature、挙動の変更 主な rename / signature 変更は次の通りです。 @@ -200,6 +200,26 @@ p = Parameter(3, name="w") pi.with_parameters({p.id: 1.0}) ``` +### 5.6 `to_qubo()` / `to_hubo()`は入力objectiveを評価結果に保持 (`3.0.0`, [#1167](https://github.com/Jij-Inc/ommx/pull/1167)) + +Driver methodは引き続き利用でき、入力をin-placeに変更します。v3では変更後の +`Instance`がQUBO/HUBO solverへ渡すminimization energyをactive objectiveとして保持し、 +`evaluate()`と`evaluate_samples()`は変換前のinstanceが公開していたobjective semanticsを +保持します。Python SDK v2はactive senseを戻したうえで最終的なpenalty energyを評価しており、 +solver inputとuser-facing outputを混在させていました。 + +したがって`Instance.objective`はsolver energyを、`Solution`と`SampleSet`は保持された入力 +objectiveを表します。実行可能な事後条件は{meth}`~ommx.Instance.to_qubo`、 +{meth}`~ommx.Instance.to_hubo`、{meth}`~ommx.Instance.evaluate`、 +{meth}`~ommx.Instance.evaluate_samples`に記載されています。 + +同じpipelineは{meth}`~ommx.Instance.prepare`と +{meth}`~ommx.Instance.as_qubo_format`または +{meth}`~ommx.Instance.as_hubo_format`で明示的に実行できます。対応するtarget classと +編集可能なpolicyは{meth}`~ommx.InstanceClass.qubo`、 +{meth}`~ommx.InstanceClass.hubo`、{meth}`~ommx.PreparationPolicy.for_qubo`、 +{meth}`~ommx.PreparationPolicy.for_hubo`が提供します。 + ## 6. return type の変更 `Constraint.name` / `Constraint.description` などは、未設定時に空文字列ではなく `None` を返します。 @@ -245,9 +265,9 @@ v3では呼び出し側がこれらの選択を所有します。Adapterが返 `PreparationPolicy` を出発点に、application固有のfieldを編集し、厳格なAdapter APIを 呼ぶ前に `Instance.prepare()` でin-placeに適用します。 -OpenJijの推奨Policyでは、特殊制約lowering、minimizationへのsense正規化、Integer slack、 -使用中Integer変数のlog encodingを有効にします。Integer slackはrange 32でexactな -equality変換を最初に試し、そのoperationが利用できない場合には、上限32のslackを +OpenJijの推奨Policyでは、特殊制約lowering、active objectiveのminimizationへの変換、 +Integer slack、使用中Integer変数のlog encodingを有効にします。Integer slackはrange 32で +exactなequality変換を最初に試し、そのoperationが利用できない場合には、上限32のslackを 追加してinequalityのまま残すことを許可します。equalityが必須なら、置き換える `IntegerSlackPreparation` の `slack_upper_bound=None` を指定します。 diff --git a/docs/ja/release_note/ommx-3.0.md b/docs/ja/release_note/ommx-3.0.md index 83f9ac9d0..9a8092870 100644 --- a/docs/ja/release_note/ommx-3.0.md +++ b/docs/ja/release_note/ommx-3.0.md @@ -8,6 +8,45 @@ Python SDK 3.0.0にはAPIの破壊的な変更が含まれます。マイグレ 直近のリリース以降にマージされた変更を、このセクションに順次追記していきます。次のリリース時に新しいバージョンのセクションへ昇格します。 +### ⚠ Solver Preparationで入力objectiveを保持 ([#1167](https://github.com/Jij-Inc/ommx/pull/1167)) + +`to_qubo()`と`to_hubo()`は、変換後のactive `Instance`にsolverが使う +minimization energyを保持しつつ、`Solution`と`SampleSet`には入力instanceが +公開していたobjective semanticsを保持するようになりました。 + +```python +from ommx import DecisionVariable, Instance, Sense + +x = DecisionVariable.binary(0) +instance = Instance.from_components( + sense=Sense.Maximize, + objective=x, + decision_variables=[x], + constraints={0: x == 1}, +) + +instance.to_qubo(uniform_penalty_weight=2.0) +state = {0: 0.0} + +assert instance.sense == Sense.Minimize +assert instance.objective.evaluate(state) == 2.0 +assert instance.evaluate(state).sense == Sense.Maximize +assert instance.evaluate(state).objective == 0.0 +assert instance.evaluate_samples({0: state}).sense == Sense.Maximize +assert instance.evaluate_samples({0: state}).objectives[0] == 0.0 +``` + +従来のdriverはactive senseを戻し、penalized solver energyを評価結果に使っていたため、 +これは最新stable Python SDKからのbreakingな修正です。返すQUBO/HUBO係数の +意味は変わりません。 + +Penalty変換後のoptimalityも保守的に変換され、active formulationのproofを +移せない評価結果は`Optimality.Unspecified`のままです。実行可能な事後条件は +{meth}`~ommx.Instance.to_qubo`、{meth}`~ommx.Instance.to_hubo`、 +{meth}`~ommx.Instance.evaluate`、{meth}`~ommx.Instance.evaluate_samples`に記載されています。 +明示的なPreparation workflowは +[Python SDK v2 to v3 Migration Guide](../migration/python_sdk_v2_to_v3.md)を参照してください。 + ### ⚠ Adapter applicability を `INPUT_CLASS` だけで定義 ([#1163](https://github.com/Jij-Inc/ommx/pull/1163)) `SolverAdapter.check_applicability()` と `require_applicable()` は、完全な diff --git a/docs/ja/user_guide/sample_set.md b/docs/ja/user_guide/sample_set.md index e54285efe..6443b847e 100644 --- a/docs/ja/user_guide/sample_set.md +++ b/docs/ja/user_guide/sample_set.md @@ -80,7 +80,7 @@ sample_set = instance.evaluate_samples(samples) sample_set.summary ``` -`summary`属性は各サンプルの目的値と実行可能性をデータフレーム形式で表示します。 `sample_id=2` のサンプルは制約条件を満たしていないので `feasible=False` となっています。このテーブルはFeasibleなものを上に、さらにその中で目的関数の値が良いもの(`Instance.sense`に応じて最大化か最小化かが変わります)を上に表示されます。 +`summary`属性は各サンプルの目的値と実行可能性をデータフレーム形式で表示します。 `sample_id=2` のサンプルは制約条件を満たしていないので `feasible=False` となっています。このテーブルはFeasibleなものを上に、さらにその中で`SampleSet.sense`に応じてより良い目的関数の値を持つものを上に表示します。 ```{note} `evaluate_samples` の引数はここでは分かり易いように `to_samples` で変換した `ommx.Samples` を渡していますが、`to_samples` は自動的に呼ばれるので省略することもできます。 diff --git a/proto/ommx/v2/common.proto b/proto/ommx/v2/common.proto index cbba871c9..b425e8246 100644 --- a/proto/ommx/v2/common.proto +++ b/proto/ommx/v2/common.proto @@ -25,6 +25,9 @@ enum Feature { FEATURE_CONSTRAINT_ONE_HOT = 2; // The payload contains first-class SOS1 constraints. FEATURE_CONSTRAINT_SOS1 = 3; + // The Instance or ParametricInstance payload explicitly carries + // output-objective semantics separately from the active solver formulation. + FEATURE_OUTPUT_OBJECTIVE = 4; } // Human-authored modeling notation for one table or collection row. diff --git a/proto/ommx/v2/instance.proto b/proto/ommx/v2/instance.proto index c3a4e26ac..a9f90a82d 100644 --- a/proto/ommx/v2/instance.proto +++ b/proto/ommx/v2/instance.proto @@ -9,6 +9,29 @@ import "ommx/v2/constraint.proto"; import "ommx/v2/decision_variable.proto"; import "ommx/v2/named_function.proto"; +// Serialized output-objective semantics distinct from the root's active objective. +// +// `sense` and `function` form one atomic pair. A validated payload carries +// both. When a root omits `output_objective`, its own `sense` and `objective` +// are also its output semantics. The pair may equal the active pair when this +// payload exists only to record that active-formulation optimality does not +// transport. +message OutputObjective { + ommx.v1.Instance.Sense sense = 1; + ommx.v1.Function function = 2; + + // Whether optimality for the active formulation also proves optimality for + // this output objective. + // + // This compares objective orderings over candidate states of the active + // formulation. It does not assert feasibility or optimality with respect to + // removed constraints. + // + // `false` is conservative: it means that such a proof is not available, + // not that the reconstructed state is known to be suboptimal. + bool preserves_optimality = 3; +} + // Validated optimization problem serialization root. message Instance { repeated Feature required_features = 1; @@ -24,4 +47,7 @@ message Instance { map decision_variable_dependency = 11; NamedFunctionTable named_functions = 12; map annotations = 13; + // Optional output-objective pair. Function references must resolve to + // decision variables owned by this root. + OutputObjective output_objective = 14; } diff --git a/proto/ommx/v2/parametric_instance.proto b/proto/ommx/v2/parametric_instance.proto index e71b6cfdd..fc3aaa008 100644 --- a/proto/ommx/v2/parametric_instance.proto +++ b/proto/ommx/v2/parametric_instance.proto @@ -7,6 +7,7 @@ import "ommx/v1/instance.proto"; import "ommx/v2/common.proto"; import "ommx/v2/constraint.proto"; import "ommx/v2/decision_variable.proto"; +import "ommx/v2/instance.proto"; import "ommx/v2/named_function.proto"; import "ommx/v2/parameter.proto"; @@ -25,4 +26,7 @@ message ParametricInstance { map decision_variable_dependency = 11; NamedFunctionTable named_functions = 12; map annotations = 13; + // Optional output-objective pair. Function references must resolve to + // decision variables or parameters owned by this root. + OutputObjective output_objective = 14; } diff --git a/python/ommx-highs-adapter/ommx_highs_adapter/adapter.py b/python/ommx-highs-adapter/ommx_highs_adapter/adapter.py index 0bb29dacb..5ec3682a7 100644 --- a/python/ommx-highs-adapter/ommx_highs_adapter/adapter.py +++ b/python/ommx-highs-adapter/ommx_highs_adapter/adapter.py @@ -517,7 +517,10 @@ class OMMXHighsAdapter(SolverAdapter): ----------------- **Variable Values**: Extracted from HiGHS ``solution.col_value`` using maintained ID mapping - **Optimality Status**: Set to ``OPTIMALITY_OPTIMAL`` when HiGHS returns ``kOptimal`` + **Optimality Status**: + + - A HiGHS ``kOptimal`` status becomes ``OPTIMALITY_UNSPECIFIED`` when it + does not transport to the output objective **Dual Variables**: Extracted from ``solution.row_dual`` for constraints @@ -792,6 +795,8 @@ def decode(self, data: highspy.Highs) -> Solution: This method translates HiGHS solver results into OMMX format, including variable values, optimality status, and dual variable information. + Backend optimality is mapped through the instance's output-objective + semantics and remains unspecified when it does not transport. Parameters ---------- @@ -805,7 +810,7 @@ def decode(self, data: highspy.Highs) -> Solution: Complete OMMX solution containing: - Variable values mapped back to original OMMX IDs - Constraint evaluations and feasibility status - - Optimality information from HiGHS + - Optimality information from HiGHS when transportable to the output objective - Dual variables for linear constraints Raises @@ -855,7 +860,9 @@ def decode(self, data: highspy.Highs) -> Solution: # set optimality if data.getModelStatus() == highspy.HighsModelStatus.kOptimal: - solution.optimality = Solution.OPTIMAL + solution.optimality = self.instance.map_active_optimality( + Solution.OPTIMAL + ) # dual variables solution_info = data.getSolution() diff --git a/python/ommx-highs-adapter/tests/test_adapter.py b/python/ommx-highs-adapter/tests/test_adapter.py index 314d1d14e..00a278da9 100644 --- a/python/ommx-highs-adapter/tests/test_adapter.py +++ b/python/ommx-highs-adapter/tests/test_adapter.py @@ -1,6 +1,6 @@ import pytest -from ommx import Instance, DecisionVariable, Solution +from ommx import DecisionVariable, Instance, Optimality, Solution from ommx.testing import SingleFeasibleLPGenerator, DataType from ommx_highs_adapter import OMMXHighsAdapter @@ -62,6 +62,22 @@ def test_solution_optimality(): assert solution.optimality == Solution.OPTIMAL +def test_solution_optimality_is_not_transported_through_fixed_penalty(): + x = DecisionVariable.binary(0) + instance = Instance.from_components( + decision_variables=[x], + objective=x, + constraints={7: x == 1}, + sense=Instance.MINIMIZE, + ) + instance.to_qubo(uniform_penalty_weight=0.0) + + solution = OMMXHighsAdapter.solve(instance) + + assert not solution.feasible + assert solution.optimality == Optimality.Unspecified + + @pytest.mark.parametrize( "generator", [ diff --git a/python/ommx-highs-adapter/tests/test_error.py b/python/ommx-highs-adapter/tests/test_error.py index 81e970202..c3dc5acd6 100644 --- a/python/ommx-highs-adapter/tests/test_error.py +++ b/python/ommx-highs-adapter/tests/test_error.py @@ -197,7 +197,7 @@ def test_recommended_preparation_reaches_the_highs_input_class(): policy = OMMXHighsAdapter.recommended_preparation_policy() assert policy.special_constraints is not None - assert policy.sense is None + assert policy.objective is None assert policy.integer_slack is None assert policy.integer_encoding is None assert policy.fixed_penalty is None diff --git a/python/ommx-openjij-adapter/README.md b/python/ommx-openjij-adapter/README.md index 5c4c65a36..1b2e58616 100644 --- a/python/ommx-openjij-adapter/README.md +++ b/python/ommx-openjij-adapter/README.md @@ -66,7 +66,7 @@ reported as conversion errors. `ommx.PreparationPolicy`. It recommends: - lowering active Indicator, OneHot, and SOS1 constraints; -- normalizing maximization to minimization; +- converting the active objective from maximization to minimization; - attempting exact Integer slack with range 32, while permitting inequality-preserving Integer slack with upper bound 32 when exact equality conversion is unavailable; and diff --git a/python/ommx-openjij-adapter/ommx_openjij_adapter/__init__.py b/python/ommx-openjij-adapter/ommx_openjij_adapter/__init__.py index 37432b31e..2b1faa8a1 100644 --- a/python/ommx-openjij-adapter/ommx_openjij_adapter/__init__.py +++ b/python/ommx-openjij-adapter/ommx_openjij_adapter/__init__.py @@ -11,7 +11,7 @@ class OMMXOpenJijSAAdapter(_OMMXOpenJijSAAdapter): Arbitrary polynomial objective degree is supported through OpenJij's QUBO and Binary-HUBO paths. - Integer encoding, sense normalization, slack introduction, and fixed + Integer encoding, active-objective conversion, slack introduction, and fixed constraint penalties are explicit preparation operations, not part of the declared input class. Start from :meth:`recommended_preparation_policy`, edit caller-owned choices such as diff --git a/python/ommx-openjij-adapter/ommx_openjij_adapter/adapter.py b/python/ommx-openjij-adapter/ommx_openjij_adapter/adapter.py index 1b8d1b0f9..841c7a94c 100644 --- a/python/ommx-openjij-adapter/ommx_openjij_adapter/adapter.py +++ b/python/ommx-openjij-adapter/ommx_openjij_adapter/adapter.py @@ -15,9 +15,9 @@ InstanceClass, InstanceClassClause, Kind, + ObjectivePreparation, PreparationPolicy, Sense, - SensePreparation, Samples, SampleSet, Solution, @@ -41,7 +41,7 @@ class OMMXOpenJijSAAdapter(SamplerAdapter): Arbitrary polynomial objective degree is supported through OpenJij's QUBO and Binary-HUBO paths. - Integer encoding, sense normalization, slack introduction, and fixed + Integer encoding, active-objective conversion, slack introduction, and fixed constraint penalties are explicit preparation operations, not part of the declared input class. Start from :meth:`recommended_preparation_policy`, edit caller-owned choices such as @@ -65,8 +65,8 @@ class OMMXOpenJijSAAdapter(SamplerAdapter): def recommended_preparation_policy(cls) -> PreparationPolicy: """Recommend the model changes commonly needed by OpenJij. - The recommendation lowers every special-constraint family, normalizes - maximization to minimization, adds Integer slack while permitting an + The recommendation lowers every special-constraint family, converts the + active objective to minimization, adds Integer slack while permitting an inequality to remain when exact equality conversion is unavailable, and log-encodes every used Integer variable. Both Integer slack ranges use 32. @@ -85,7 +85,7 @@ def recommended_preparation_policy(cls) -> PreparationPolicy: SpecialConstraintKind.Sos1, } ), - sense=SensePreparation.as_minimization_problem(), + objective=ObjectivePreparation(target=Sense.Minimize), integer_slack=IntegerSlackPreparation( max_integer_range=32, slack_upper_bound=32, diff --git a/python/ommx-openjij-adapter/tests/test_applicability.py b/python/ommx-openjij-adapter/tests/test_applicability.py index ab0046d6f..f93440b3c 100644 --- a/python/ommx-openjij-adapter/tests/test_applicability.py +++ b/python/ommx-openjij-adapter/tests/test_applicability.py @@ -11,6 +11,7 @@ Instance, InstanceClassMismatch, Kind, + ObjectivePreparation, OneHotConstraint, PreparationTargetNotReachedError, Sense, @@ -219,6 +220,7 @@ def test_recommended_policy_keeps_fixed_penalty_caller_owned() -> None: input_class = OMMXOpenJijSAAdapter.INPUT_CLASS policy = OMMXOpenJijSAAdapter.recommended_preparation_policy() + assert policy.objective == ObjectivePreparation(target=Sense.Minimize) assert policy.fixed_penalty is None with pytest.raises(PreparationTargetNotReachedError): instance.prepare(input_class, policy) diff --git a/python/ommx-openjij-adapter/tests/test_experiment.py b/python/ommx-openjij-adapter/tests/test_experiment.py index 6ed2c0d1d..eea805d58 100644 --- a/python/ommx-openjij-adapter/tests/test_experiment.py +++ b/python/ommx-openjij-adapter/tests/test_experiment.py @@ -19,6 +19,7 @@ def test_log_sample_records_the_exact_prepared_adapter_input() -> None: ) source.prepare(input_class, policy) adapter_input = source + assert adapter_input.sense == Sense.Minimize input_bytes = adapter_input.to_v2_bytes() experiment = Experiment.with_temp_local_registry() prepared_samples: SampleSet | None = None @@ -32,7 +33,7 @@ def test_log_sample_records_the_exact_prepared_adapter_input() -> None: ) assert prepared_samples is not None - assert prepared_samples.sense == Sense.Minimize + assert prepared_samples.sense == Sense.Maximize for sample_id in prepared_samples.sample_ids(): actual = prepared_samples.get(sample_id) expected = adapter_input.evaluate(actual.state) diff --git a/python/ommx-pyscipopt-adapter/ommx_pyscipopt_adapter/adapter.py b/python/ommx-pyscipopt-adapter/ommx_pyscipopt_adapter/adapter.py index 46bad1766..70ce6c1c4 100644 --- a/python/ommx-pyscipopt-adapter/ommx_pyscipopt_adapter/adapter.py +++ b/python/ommx-pyscipopt-adapter/ommx_pyscipopt_adapter/adapter.py @@ -694,6 +694,10 @@ def decode(self, data: pyscipopt.Model) -> Solution: effectively the same problem as the OMMX instance used to create the adapter. + Backend optimality is mapped through the instance's output-objective + semantics. It remains unspecified when active-formulation optimality + does not transport to that objective. + Examples ========= @@ -732,7 +736,9 @@ def decode(self, data: pyscipopt.Model) -> Solution: if ( data.getStatus() == "optimal" ): # pyscipopt does not appear to have an enum or constant for this - solution.optimality = Solution.OPTIMAL + solution.optimality = self.instance.map_active_optimality( + Solution.OPTIMAL + ) return solution diff --git a/python/ommx-pyscipopt-adapter/tests/test_ommx_pyscipopt.py b/python/ommx-pyscipopt-adapter/tests/test_ommx_pyscipopt.py index 9dd816595..2a590a70c 100644 --- a/python/ommx-pyscipopt-adapter/tests/test_ommx_pyscipopt.py +++ b/python/ommx-pyscipopt-adapter/tests/test_ommx_pyscipopt.py @@ -7,7 +7,7 @@ from ommx.adapter import DiagnosticCollector, UnboundedDetected from ommx.experiment import Experiment -from ommx import Instance, Constraint, DecisionVariable, Solution +from ommx import Constraint, DecisionVariable, Instance, Optimality, Solution from ommx_pyscipopt_adapter import ( SCIPDiagnosticsAnalyzer, @@ -163,6 +163,22 @@ def test_solution_optimality(): assert solution.optimality == Solution.OPTIMAL +def test_solution_optimality_is_not_transported_through_fixed_penalty(): + x = DecisionVariable.binary(0) + instance = Instance.from_components( + decision_variables=[x], + objective=x, + constraints={7: x == 1}, + sense=Instance.MINIMIZE, + ) + instance.to_qubo(uniform_penalty_weight=0.0) + + solution = OMMXPySCIPOptAdapter.solve(instance) + + assert not solution.feasible + assert solution.optimality == Optimality.Unspecified + + def test_direct_solve_records_termination_report(): x = DecisionVariable.integer(1, lower=0, upper=5) instance = Instance.from_components( diff --git a/python/ommx-python-mip-adapter/ommx_python_mip_adapter/adapter.py b/python/ommx-python-mip-adapter/ommx_python_mip_adapter/adapter.py index e3b79ecfc..1ac0a9c74 100644 --- a/python/ommx-python-mip-adapter/ommx_python_mip_adapter/adapter.py +++ b/python/ommx-python-mip-adapter/ommx_python_mip_adapter/adapter.py @@ -270,6 +270,10 @@ def decode(self, data: mip.Model) -> Solution: `solver_input`, you must set `solution.relaxation` yourself if you care about this value. + Backend optimality is mapped through the instance's output-objective + semantics. It remains unspecified when active-formulation optimality + does not transport to that objective. + Examples ========= @@ -313,7 +317,9 @@ def decode(self, data: mip.Model) -> Solution: solution.set_dual_variable(constraint_id, dual_value) if data.status == mip.OptimizationStatus.OPTIMAL: - solution.optimality = Solution.OPTIMAL + solution.optimality = self.instance.map_active_optimality( + Solution.OPTIMAL + ) if self._relax: solution.relaxation = Solution.LP_RELAXED diff --git a/python/ommx-python-mip-adapter/tests/test_adapter.py b/python/ommx-python-mip-adapter/tests/test_adapter.py index 5922bf5e8..8bd8ae4fc 100644 --- a/python/ommx-python-mip-adapter/tests/test_adapter.py +++ b/python/ommx-python-mip-adapter/tests/test_adapter.py @@ -193,7 +193,7 @@ def test_recommended_preparation_reaches_the_python_mip_input_class() -> None: policy = OMMXPythonMIPAdapter.recommended_preparation_policy() assert policy.special_constraints is not None - assert policy.sense is None + assert policy.objective is None assert policy.integer_slack is None assert policy.integer_encoding is None assert policy.fixed_penalty is None diff --git a/python/ommx-python-mip-adapter/tests/test_integration.py b/python/ommx-python-mip-adapter/tests/test_integration.py index 5045a616a..19720a395 100644 --- a/python/ommx-python-mip-adapter/tests/test_integration.py +++ b/python/ommx-python-mip-adapter/tests/test_integration.py @@ -1,6 +1,6 @@ import pytest -from ommx import Instance, DecisionVariable, Solution +from ommx import DecisionVariable, Instance, Optimality, Solution from ommx.adapter import InfeasibleDetected, UnboundedDetected, NoSolutionReturned from ommx.testing import SingleFeasibleLPGenerator, DataType @@ -75,6 +75,22 @@ def test_solution_optimality(): assert solution.optimality == Solution.OPTIMAL +def test_solution_optimality_is_not_transported_through_fixed_penalty(): + x = DecisionVariable.binary(0) + instance = Instance.from_components( + decision_variables=[x], + objective=x, + constraints={7: x == 1}, + sense=Instance.MINIMIZE, + ) + instance.to_qubo(uniform_penalty_weight=0.0) + + solution = OMMXPythonMIPAdapter.solve(instance) + + assert not solution.feasible + assert solution.optimality == Optimality.Unspecified + + def test_partial_evaluate(): x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] instance = Instance.from_components( diff --git a/python/ommx-tests/tests/test_doctests.py b/python/ommx-tests/tests/test_doctests.py index 1db746e9b..e8258e7b2 100644 --- a/python/ommx-tests/tests/test_doctests.py +++ b/python/ommx-tests/tests/test_doctests.py @@ -1,16 +1,93 @@ import doctest -import ommx -import pkgutil import importlib +import inspect +import pkgutil +from collections.abc import Iterator +from types import ModuleType + +import ommx +import pytest -def test_doctest(): - result = doctest.testmod(ommx, optionflags=doctest.ELLIPSIS) - assert result.failed == 0 - # type: ignore - for mod in pkgutil.iter_modules(ommx.__path__): - if mod.name == "v1": +def iter_package_modules() -> Iterator[ModuleType]: + yield ommx + for module_info in pkgutil.walk_packages(ommx.__path__, prefix="ommx."): + if module_info.name == "ommx._ommx_rust": continue - mod = importlib.import_module(f"ommx.{mod.name}") - result = doctest.testmod(mod, optionflags=doctest.ELLIPSIS) - assert result.failed == 0 + if module_info.name == "ommx.v1" or module_info.name.startswith("ommx.v1."): + continue + yield importlib.import_module(module_info.name) + + +def iter_docstring_owners() -> Iterator[tuple[str, object, ModuleType]]: + """Yield each package-owned or publicly re-exported docstring owner once.""" + seen: set[int] = set() + + def walk( + owner_path: str, owner: object, module: ModuleType + ) -> Iterator[tuple[str, object, ModuleType]]: + if id(owner) in seen: + return + seen.add(id(owner)) + yield owner_path, owner, module + + if not inspect.isclass(owner): + return + + for member_name, raw_member in vars(owner).items(): + if not ( + inspect.isroutine(raw_member) + or inspect.isdatadescriptor(raw_member) + or inspect.isclass(raw_member) + ): + continue + if isinstance(raw_member, (classmethod, staticmethod)): + member = raw_member.__func__ + else: + member = raw_member + yield from walk(f"{owner_path}.{member_name}", member, module) + + for module in iter_package_modules(): + module_name = module.__name__ + yield from walk(module_name, module, module) + + for export_name in getattr(module, "__all__", ()): + yield from walk( + f"{module_name}.{export_name}", getattr(module, export_name), module + ) + + for owner_name, owner in vars(module).items(): + if not (inspect.isclass(owner) or inspect.isroutine(owner)): + continue + if getattr(owner, "__module__", None) != module_name: + continue + yield from walk(f"{module_name}.{owner_name}", owner, module) + + +def parse_docstring( + owner_path: str, owner: object, module: ModuleType +) -> doctest.DocTest | None: + raw_doc = getattr(owner, "__doc__", None) + if not isinstance(raw_doc, str): + return None + doc = inspect.cleandoc(raw_doc) + test = doctest.DocTestParser().get_doctest( + doc, + dict(vars(module)), + owner_path, + f"", + 0, + ) + return test if test.examples else None + + +DOCTESTS = tuple( + test + for owner_path, owner, module in iter_docstring_owners() + if (test := parse_docstring(owner_path, owner, module)) is not None +) + + +@pytest.mark.parametrize("test", DOCTESTS, ids=lambda test: test.name) +def test_doctest(test: doctest.DocTest) -> None: + doctest.DebugRunner(optionflags=doctest.ELLIPSIS).run(test) diff --git a/python/ommx-tests/tests/test_instance.py b/python/ommx-tests/tests/test_instance.py index 961539e9a..30bd3dd53 100644 --- a/python/ommx-tests/tests/test_instance.py +++ b/python/ommx-tests/tests/test_instance.py @@ -8,10 +8,13 @@ Function, InfeasibleDetected, Instance, + InstanceClass, Linear, LogEncodingError, Parameter, ParametricInstance, + PreparationPolicy, + Sense, ) @@ -142,20 +145,6 @@ def test_add_constraint_accepts_full_modeling_label(): assert snapshot.description == "existing description" -def test_set_objective(): - x = [DecisionVariable.binary(i) for i in range(3)] - instance = Instance.from_components( - decision_variables=x, - objective=sum(x), - constraints={}, - sense=Instance.MAXIMIZE, - ) - assert instance.objective.almost_equal(Function(sum(x))) - - instance.objective = x[1] - assert instance.objective.almost_equal(Function(x[1])) - - def test_convert_inequality_to_equality_with_integer_slack_limit(): x = [DecisionVariable.binary(i) for i in range(3)] instance = Instance.from_components( @@ -299,40 +288,104 @@ def test_to_qubo_penalty_weight(): assert offset == 2.0 -def test_to_qubo_continuous(): - x = [DecisionVariable.continuous(i, lower=-1.23, upper=4.56) for i in range(3)] - instance = Instance.from_components( - decision_variables=x, - objective=sum(x), - constraints={0: x[0] + x[1] >= 7.89}, - sense=Instance.MAXIMIZE, +def _binary_polynomial_energy( + coefficients: dict[tuple[int, ...], float], + offset: float, + state: dict[int, float], +) -> float: + return offset + sum( + coefficient * math.prod(state[variable_id] for variable_id in ids) + for ids, coefficient in coefficients.items() ) - with pytest.raises(ValueError) as e: - instance.to_qubo() - assert ( - str(e.value) - == "Continuous variables are not supported in QUBO conversion: IDs=[0, 1, 2]" + + +@pytest.mark.parametrize( + ("driver_name", "policy_name", "class_name", "format_name"), + [ + ("to_qubo", "for_qubo", "qubo", "as_qubo_format"), + ("to_hubo", "for_hubo", "hubo", "as_hubo_format"), + ], +) +def test_qubo_hubo_driver_matches_explicit_preparation_and_preserves_output( + driver_name: str, + policy_name: str, + class_name: str, + format_name: str, +) -> None: + x = DecisionVariable.integer(0, lower=0, upper=3) + source = Instance.from_components( + decision_variables=[x], + objective=3 * x + 5, + constraints={7: x <= 2}, + sense=Sense.Maximize, + ) + driver = Instance.from_v2_bytes(source.to_v2_bytes()) + explicit = Instance.from_v2_bytes(source.to_v2_bytes()) + kwargs = { + "uniform_penalty_weight": 2.0, + "inequality_integer_slack_max_range": 7, + } + + actual_coefficients, actual_offset = getattr(driver, driver_name)(**kwargs) + policy = getattr(PreparationPolicy, policy_name)(**kwargs) + explicit.prepare(getattr(InstanceClass, class_name)(), policy) + expected_coefficients, expected_offset = getattr(explicit, format_name)() + + assert actual_coefficients == expected_coefficients + assert actual_offset == expected_offset + assert driver.to_v2_bytes() == explicit.to_v2_bytes() + assert driver.sense == Sense.Minimize + + active_state = {variable_id: 0.0 for variable_id in driver.required_ids()} + active_energy = _binary_polynomial_energy( + actual_coefficients, + actual_offset, + active_state, + ) + assert driver.objective.evaluate(active_state) == pytest.approx(active_energy) + + solution = driver.evaluate(active_state) + sample_set = driver.evaluate_samples({11: active_state}) + assert solution.sense == Sense.Maximize + assert solution.objective == pytest.approx(5.0) + assert sample_set.sense == Sense.Maximize + assert sample_set.objectives[11] == pytest.approx(5.0) + + +def test_to_qubo_reduces_repeated_binary_power() -> None: + x = DecisionVariable.binary(0) + instance = Instance.from_components( + decision_variables=[x], + objective=x * x * x, + constraints={}, + sense=Sense.Minimize, ) + qubo, offset = instance.to_qubo() -def test_to_qubo_invalid_penalty_option(): - x = [ - DecisionVariable.integer(i, lower=0, upper=2, name="x", subscripts=[i]) - for i in range(2) - ] + assert qubo == {(0, 0): 1.0} + assert offset == 0.0 + assert InstanceClass.qubo().contains(instance) + assert instance.evaluate({0: 1}).objective == 1.0 + + +@pytest.mark.parametrize("method_name", ["to_qubo", "to_hubo"]) +def test_qubo_hubo_continuous_partial_failure(method_name: str) -> None: + x = [DecisionVariable.continuous(i, lower=-1.23, upper=4.56) for i in range(3)] instance = Instance.from_components( decision_variables=x, objective=sum(x), - constraints={0: x[0] + 2 * x[1] <= 3}, + constraints={0: x[0] + x[1] >= 7.89}, sense=Instance.MAXIMIZE, ) - - with pytest.raises(ValueError) as e: - instance.to_qubo(uniform_penalty_weight=1.0, penalty_weights={0: 2.0}) - assert ( - str(e.value) - == "Both uniform_penalty_weight and penalty_weights are specified. Please choose one." - ) + with pytest.raises( + RuntimeError, + match=r"The constraint contains continuous decision variables: " + r"ID=VariableID\(0\)", + ): + getattr(instance, method_name)() + assert instance.sense == Sense.Minimize + assert instance.evaluate({0: 0, 1: 0, 2: 0}).sense == Sense.Maximize def test_hubo_3rd_degree(): @@ -362,42 +415,6 @@ def test_to_hubo_penalty_weight(): assert offset == 2.0 -def test_to_hubo_continuous(): - x = [DecisionVariable.continuous(i, lower=-1.23, upper=4.56) for i in range(3)] - instance = Instance.from_components( - decision_variables=x, - objective=sum(x), - constraints={0: x[0] + x[1] >= 7.89}, - sense=Instance.MAXIMIZE, - ) - with pytest.raises(ValueError) as e: - instance.to_hubo() - assert ( - str(e.value) - == "Continuous variables are not supported in HUBO conversion: IDs=[0, 1, 2]" - ) - - -def test_to_hubo_invalid_penalty_option(): - x = [ - DecisionVariable.integer(i, lower=0, upper=2, name="x", subscripts=[i]) - for i in range(2) - ] - instance = Instance.from_components( - decision_variables=x, - objective=sum(x), - constraints={0: x[0] + 2 * x[1] <= 3}, - sense=Instance.MAXIMIZE, - ) - - with pytest.raises(ValueError) as e: - instance.to_hubo(uniform_penalty_weight=1.0, penalty_weights={0: 2.0}) - assert ( - str(e.value) - == "Both uniform_penalty_weight and penalty_weights are specified. Please choose one." - ) - - def test_evaluate_irrelevant_binary_variable(): x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] instance = Instance.from_components( diff --git a/python/ommx-tests/tests/test_instance_class.py b/python/ommx-tests/tests/test_instance_class.py index 565519c02..6a65d9ba5 100644 --- a/python/ommx-tests/tests/test_instance_class.py +++ b/python/ommx-tests/tests/test_instance_class.py @@ -15,10 +15,10 @@ InstanceClassMembershipReport, InstanceClassMismatch, Kind, + ObjectivePreparation, OneHotConstraint, PreparationPolicy, Sense, - SensePreparation, Sos1Constraint, SpecialConstraintKind, ) @@ -335,8 +335,8 @@ def test_solver_adapter_returns_a_fresh_empty_preparation_recommendation() -> No assert second == PreparationPolicy() assert first is not second - first.sense = SensePreparation.as_minimization_problem() - assert second.sense is None + first.objective = ObjectivePreparation(target=Sense.Minimize) + assert second.objective is None def test_solver_adapter_applicability_is_input_class_membership() -> None: diff --git a/python/ommx-tests/tests/test_instance_error_translation.py b/python/ommx-tests/tests/test_instance_error_translation.py index aaed4a0d9..ea028c6eb 100644 --- a/python/ommx-tests/tests/test_instance_error_translation.py +++ b/python/ommx-tests/tests/test_instance_error_translation.py @@ -63,6 +63,8 @@ def test_to_qubo_rejects_missing_penalty_weight(): ) with pytest.raises( - ValueError, match="No penalty weight provided for constraint ID 456" + RuntimeError, + match=r"Fixed penalty weights must match active regular constraint IDs: " + r"missing \[ConstraintID\(456\)\], unexpected \[\]", ): instance.to_qubo(penalty_weights={123: 1.0}) diff --git a/python/ommx-tests/tests/test_preparation.py b/python/ommx-tests/tests/test_preparation.py index f2f753e6f..10195d096 100644 --- a/python/ommx-tests/tests/test_preparation.py +++ b/python/ommx-tests/tests/test_preparation.py @@ -13,11 +13,11 @@ IntegerEncodingPreparation, IntegerSlackPreparation, Kind, + ObjectivePreparation, OneHotConstraint, PreparationPolicy, PreparationTargetNotReachedError, Sense, - SensePreparation, SpecialConstraintKind, SpecialConstraintPreparation, ) @@ -60,7 +60,7 @@ def test_prepare_mutates_in_place_and_establishes_target_membership() -> None: policy.special_constraints = SpecialConstraintPreparation.lower_special_constraints( kinds={SpecialConstraintKind.OneHot} ) - policy.sense = SensePreparation.as_minimization_problem() + policy.objective = ObjectivePreparation(target=Sense.Minimize) policy.integer_slack = IntegerSlackPreparation( max_integer_range=1, slack_upper_bound=2, @@ -89,7 +89,7 @@ def test_prepare_preserves_owner_signal_and_earlier_commits() -> None: objective_degree=1, ) policy = PreparationPolicy( - sense=SensePreparation.as_minimization_problem(), + objective=ObjectivePreparation(target=Sense.Minimize), integer_slack=IntegerSlackPreparation(max_integer_range=1), ) diff --git a/python/ommx-tests/tests/test_tracing.py b/python/ommx-tests/tests/test_tracing.py index 4b8e1313b..8db318954 100644 --- a/python/ommx-tests/tests/test_tracing.py +++ b/python/ommx-tests/tests/test_tracing.py @@ -160,9 +160,8 @@ def test_evaluate_emits_rust_span_under_python_parent() -> None: assert s.context.trace_id == parent_trace_id -def test_to_qubo_emits_pipeline_spans() -> None: - """``Instance.to_qubo`` exercises the nested QUBO conversion pipeline, - producing the expected span names on the Rust side.""" +def test_to_qubo_emits_active_format_span() -> None: + """``Instance.to_qubo`` keeps its Rust-side format operation observable.""" exporter = get_test_exporter() provider = get_test_provider() exporter.clear() @@ -179,10 +178,6 @@ def test_to_qubo_emits_pipeline_spans() -> None: provider.force_flush() names = {s.name for s in exporter.spans} - # The PyO3 pipeline wrapper and the Rust-side formatter both emit spans. - assert "qubo_hubo_pipeline" in names, ( - f"Missing 'qubo_hubo_pipeline' span. Got: {sorted(names)}" - ) assert "as_qubo_format" in names, ( f"Missing 'as_qubo_format' span. Got: {sorted(names)}" ) diff --git a/python/ommx/Cargo.toml b/python/ommx/Cargo.toml index 37088b7bf..72a5e9962 100644 --- a/python/ommx/Cargo.toml +++ b/python/ommx/Cargo.toml @@ -17,6 +17,9 @@ workspace = true [lib] crate-type = ["cdylib", "rlib"] +# Doc comments in this binding crate are Python/Sphinx doctests, not Rustdoc. +doc = false +doctest = false [[bin]] name = "stub_gen" diff --git a/python/ommx/ommx/__init__.py b/python/ommx/ommx/__init__.py index 0cf95b38c..379a74a8f 100644 --- a/python/ommx/ommx/__init__.py +++ b/python/ommx/ommx/__init__.py @@ -7,6 +7,7 @@ AttachedIndicatorConstraint, AttachedOneHotConstraint, AttachedSos1Constraint, + BinaryPowerPreparation, Bound, Constraint, DecisionVariable, @@ -33,6 +34,7 @@ Linear, LogEncodingError, NamedFunction, + ObjectivePreparation, OneHotConstraint, Optimality, Parameter, @@ -55,7 +57,6 @@ SampledNamedFunction, Samples, Sense, - SensePreparation, Solution, Sos1Constraint, SpecialConstraintKind, @@ -74,6 +75,7 @@ "AttachedIndicatorConstraint", "AttachedOneHotConstraint", "AttachedSos1Constraint", + "BinaryPowerPreparation", "Bound", "Constraint", "DecisionVariable", @@ -100,6 +102,7 @@ "Linear", "LogEncodingError", "NamedFunction", + "ObjectivePreparation", "OneHotConstraint", "Optimality", "Parameter", @@ -122,7 +125,6 @@ "SampledNamedFunction", "Samples", "Sense", - "SensePreparation", "Solution", "Sos1Constraint", "SpecialConstraintKind", diff --git a/python/ommx/ommx/_ommx_rust/__init__.pyi b/python/ommx/ommx/_ommx_rust/__init__.pyi index 0f01bd111..8bdef8c43 100644 --- a/python/ommx/ommx/_ommx_rust/__init__.pyi +++ b/python/ommx/ommx/_ommx_rust/__init__.pyi @@ -31,6 +31,7 @@ __all__ = [ "AttachedOneHotConstraint", "AttachedSos1Constraint", "AutosavePolicy", + "BinaryPowerPreparation", "Bound", "Constraint", "DecisionVariable", @@ -70,6 +71,7 @@ __all__ = [ "LinearLike", "LogEncodingError", "NamedFunction", + "ObjectivePreparation", "OneHotConstraint", "OpenSolve", "Optimality", @@ -104,7 +106,6 @@ __all__ = [ "ScalarLike", "SealedRun", "Sense", - "SensePreparation", "Solution", "Solve", "Sos1Constraint", @@ -275,12 +276,9 @@ class Artifact: An artifact is an OCI container image that stores OMMX data (instances, solutions, sample sets, etc.) as layers. - ```python >>> artifact = Artifact.load("ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f") >>> print(artifact.image_name) ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f - - ``` """ TRACE_OTLP_PROTOBUF_MEDIA_TYPE: builtins.str @@ -353,12 +351,9 @@ class Artifact: archives importable while still making the imported artifact addressable in SQLite. - ```python >>> artifact = Artifact.import_archive("data/random_lp_instance.ommx") >>> print(artifact.image_name) ghcr.io/jij-inc/ommx/random_lp_instance:... - - ``` """ @staticmethod def load_archive(path: builtins.str | os.PathLike | pathlib.Path) -> Artifact: @@ -397,13 +392,10 @@ class Artifact: `Artifact.load(image_name)` later), use {meth}`Artifact.import_archive`. - ```python >>> manifest = Artifact.inspect_archive("data/random_lp_instance.ommx") >>> for layer in manifest.layers: ... print(layer.media_type) application/org.ommx.v1.instance - - ``` """ @staticmethod def load(image_name: builtins.str) -> Artifact: @@ -412,12 +404,10 @@ class Artifact: If the image is not found in local registry, it will try to pull from remote registry. - ```python >>> artifact = Artifact.load("ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f") >>> print(artifact.image_name) ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f - ``` Raises {class}`~ommx.artifact.RemoteArtifactNotFoundError` when the exact remote reference does not exist. Other remote access failures @@ -546,13 +536,10 @@ class ArtifactDraft: r""" Mutable draft for OMMX Artifacts. - ```python >>> draft = ArtifactDraft.temp() >>> artifact = draft.commit() >>> print(artifact.image_name) ttl.sh/...-...-...-...-...:1h - - ``` """ @staticmethod def new(image_name: builtins.str) -> ArtifactDraft: @@ -563,7 +550,6 @@ class ArtifactDraft: returned handle if you also want a `.ommx` archive file for sharing. - ```python >>> from ommx.testing import SingleFeasibleLPGenerator, DataType >>> generator = SingleFeasibleLPGenerator(3, DataType.INT) >>> instance = generator.get_v1_instance() @@ -575,7 +561,6 @@ class ArtifactDraft: >>> print(artifact.image_name) ghcr.io/jij-inc/ommx/single_feasible_lp:... - ``` Raises {class}`ValueError` when `image_name` is not a valid OCI image reference. Registry and storage failures raise {class}`RuntimeError`. @@ -614,7 +599,6 @@ class ArtifactDraft: Call {meth}`Artifact.save(path)` on the returned handle to also write a `.ommx` archive file for sharing. - ```python >>> from ommx.testing import SingleFeasibleLPGenerator, DataType >>> generator = SingleFeasibleLPGenerator(3, DataType.INT) >>> instance = generator.get_v1_instance() @@ -622,8 +606,6 @@ class ArtifactDraft: >>> _desc = draft.add_instance(instance) >>> artifact = draft.commit() >>> assert ".ommx.local/anonymous:" in artifact.image_name - - ``` """ @staticmethod def temp() -> ArtifactDraft: @@ -632,13 +614,10 @@ class ArtifactDraft: Insecure; for tests only. `ttl.sh` is a public registry that expires images after one hour. - ```python >>> draft = ArtifactDraft.temp() >>> artifact = draft.commit() >>> print(artifact.image_name) ttl.sh/...-...-...-...-...:1h - - ``` """ @staticmethod def for_github( @@ -655,7 +634,6 @@ class ArtifactDraft: r""" Add an {class}`~ommx.Instance` to the artifact with annotations. - ```python >>> from ommx import Instance >>> instance = Instance.minimize() >>> instance.title = "test instance" @@ -663,8 +641,6 @@ class ArtifactDraft: >>> desc = draft.add_instance(instance) >>> print(desc.annotations['org.ommx.v1.instance.title']) test instance - - ``` """ def add_parametric_instance(self, instance: ParametricInstance) -> Descriptor: r""" @@ -688,7 +664,6 @@ class ArtifactDraft: r""" Add a numpy ndarray to the artifact with npy format. - ```python >>> import numpy as np >>> array = np.array([1, 2, 3]) >>> draft = ArtifactDraft.temp() @@ -699,8 +674,6 @@ class ArtifactDraft: application/vnd.numpy >>> print(layer.annotations) {'org.ommx.user.title': 'test_array'} - - ``` """ def add_dataframe( self, @@ -712,7 +685,6 @@ class ArtifactDraft: r""" Add a pandas DataFrame to the artifact with parquet format. - ```python >>> import pandas as pd >>> df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) >>> draft = ArtifactDraft.temp() @@ -721,8 +693,6 @@ class ArtifactDraft: >>> layer = artifact.layers[0] >>> print(layer.media_type) application/vnd.apache.parquet - - ``` """ def add_json( self, @@ -734,7 +704,6 @@ class ArtifactDraft: r""" Add a JSON object to the artifact. - ```python >>> obj = {"a": 1, "b": 2} >>> draft = ArtifactDraft.temp() >>> _desc = draft.add_json(obj, title="test_json") @@ -742,8 +711,6 @@ class ArtifactDraft: >>> layer = artifact.layers[0] >>> print(layer.media_type) application/json - - ``` """ def add_layer( self, @@ -1303,6 +1270,14 @@ class AutosavePolicy: """ def __repr__(self) -> builtins.str: ... +@typing.final +class BinaryPowerPreparation: + r""" + Reduce powers of active Binary variables during Preparation. + """ + def __eq__(self, other: builtins.object, /) -> builtins.bool: ... + def __new__(cls) -> BinaryPowerPreparation: ... + @typing.final class Bound: r""" @@ -1501,19 +1476,15 @@ class DecisionVariable: # Examples - ```python >>> x = DecisionVariable.integer(1) >>> x == 1 # Returns Constraint, not bool Constraint(...) - ``` For object equality comparison, use the ``equals_to()`` method or compare IDs: - ```python >>> y = DecisionVariable.integer(2) >>> x.id == y.id False - ``` """ BINARY: builtins.int = 1 @@ -2649,13 +2620,11 @@ class Function: # Examples - ```python >>> from ommx import Function, Linear, Bound >>> f = Function(Linear(terms={1: 2}, constant=3)) # 2*x1 + 3 >>> b = f.evaluate_bound({1: Bound(0.0, 2.0)}) >>> (b.lower, b.upper) (3.0, 7.0) - ``` """ def __copy__(self) -> Function: ... def __deepcopy__(self, _memo: typing.Any) -> Function: ... @@ -2908,49 +2877,27 @@ class Instance: r""" Optimization problem instance. - This class also contains annotations like {attr}`~ommx.Instance.title`. - OMMX-defined annotations are stored in explicit protobuf fields, while - user-defined annotations are stored in the protobuf annotation map and - mirrored to OMMX Artifact descriptors. - - # Examples - - Create an instance for KnapSack Problem - - ```python - >>> from ommx import Instance, DecisionVariable - ``` - - Profit and weight of items + # Invariants - ```python - >>> p = [10, 13, 18, 31, 7, 15] - >>> w = [11, 15, 20, 35, 10, 33] - ``` + Output-only variables are excluded from solver input and evaluated after the full state is populated. - Decision variables - - ```python - >>> x = [DecisionVariable.binary(i) for i in range(6)] - ``` - - Objective and constraint - - ```python - >>> objective = sum(p[i] * x[i] for i in range(6)) - >>> constraint = sum(w[i] * x[i] for i in range(6)) <= 47 - ``` - - Compose as an instance - - ```python + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=objective, - ... constraints=[constraint], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], + ... objective=3 * x, + ... constraints={}, + ... sense=Sense.Maximize, ... ) - ``` + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> fixed = instance.partial_evaluate({0: 1}) + >>> assert fixed.sense == Sense.Minimize + >>> assert fixed.objective.evaluate({}) == -3.0 + >>> assert fixed.required_ids() == set() + >>> assert fixed.used_decision_variables == [] + >>> assert fixed.populate_state({}).entries == {0: 1.0} + >>> solution = fixed.evaluate({}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) """ MAXIMIZE: Sense @@ -2989,9 +2936,44 @@ class Instance: @created.setter def created(self, value: datetime.datetime) -> None: ... @property - def sense(self) -> Sense: ... + def sense(self) -> Sense: + r""" + Active optimization sense used by the solver-facing formulation. + + # Postconditions + + The property reports the active sense even when evaluation uses a distinct output sense. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> assert instance.sense == Sense.Minimize + >>> assert instance.evaluate({0: 1}).sense == Sense.Maximize + """ @property - def objective(self) -> Function: ... + def objective(self) -> Function: + r""" + Active objective used by the solver-facing formulation. + + # Postconditions + + Assignment replaces the active objective and rebases subsequent output evaluation onto it. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> assert instance.objective.evaluate({0: 1}) == -1.0 + >>> instance.objective = 2 * x + >>> solution = instance.evaluate({0: 1}) + >>> assert instance.sense == Sense.Minimize + >>> assert (solution.sense, solution.objective) == (Sense.Minimize, 2.0) + """ @objective.setter def objective(self, value: ToFunction) -> None: ... @property @@ -3175,12 +3157,10 @@ class Instance: # Examples - ```python >>> from ommx import Instance >>> instance = Instance.minimize() >>> instance.sense == Instance.MINIMIZE True - ``` """ @staticmethod def minimize() -> Instance: @@ -3334,8 +3314,47 @@ class Instance: Raises if any underlying Big-M conversion fails (e.g. a SOS1 variable with a non-finite bound). """ - def to_v1_bytes(self) -> bytes: ... - def to_v2_bytes(self) -> bytes: ... + def to_v1_bytes(self) -> bytes: + r""" + Serialize this instance in the OMMX v1 wire format. + + # Errors + + Serialization raises ``RuntimeError`` when distinct output semantics cannot be represented by v1. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> try: + ... instance.to_v1_bytes() + ... except RuntimeError: + ... pass + ... else: + ... raise AssertionError("v1 serialization accepted distinct output semantics") + """ + def to_v2_bytes(self) -> bytes: + r""" + Serialize this instance in the OMMX v2 wire format. + + # Postconditions + + A v2 round-trip preserves both active and output objective semantics. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> restored = Instance.from_v2_bytes(instance.to_v2_bytes()) + >>> assert restored.sense == Sense.Minimize + >>> assert restored.objective.evaluate({0: 1}) == -3.0 + >>> solution = restored.evaluate({0: 1}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) + """ def __str__(self) -> builtins.str: ... def __repr__(self) -> builtins.str: ... def format_function( @@ -3366,25 +3385,60 @@ class Instance: """ def required_ids(self) -> builtins.set[builtins.int]: r""" - Get the set of decision variable IDs used in the objective and remaining constraints. + Get the decision variable IDs required by the active formulation. - # Examples + # Postconditions - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = [DecisionVariable.binary(i) for i in range(3)] + IDs referenced only by preserved output semantics are not required solver inputs. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize ... ) - >>> instance.required_ids() - {0, 1, 2} - ``` + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> fixed = instance.partial_evaluate({0: 1}) + >>> assert fixed.required_ids() == set() + >>> assert fixed.evaluate({}).objective == 1.0 + """ + def as_qubo_format(self) -> tuple[dict, builtins.float]: + r""" + Return the active objective in QUBO format without preparing the instance. + + # Postconditions + + The returned coefficients represent the active objective rather than preserved output semantics. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=3 * x + 5, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> qubo, offset = instance.as_qubo_format() + >>> assert (qubo, offset) == ({(0, 0): -3.0}, -5.0) + >>> assert instance.objective.evaluate({0: 1}) == -8.0 + >>> assert instance.evaluate({0: 1}).objective == 8.0 + """ + def as_hubo_format(self) -> tuple[dict, builtins.float]: + r""" + Return the active objective in HUBO format without preparing the instance. + + # Postconditions + + The returned coefficients represent the active objective rather than preserved output semantics. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=3 * x + 5, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> hubo, offset = instance.as_hubo_format() + >>> assert (hubo, offset) == ({(0,): -3.0}, -5.0) + >>> assert instance.objective.evaluate({0: 1}) == -8.0 + >>> assert instance.evaluate({0: 1}).objective == 8.0 """ - def as_qubo_format(self) -> tuple[dict, builtins.float]: ... - def as_hubo_format(self) -> tuple[dict, builtins.float]: ... def to_qubo( self, *, @@ -3397,60 +3451,43 @@ class Instance: r""" Convert the instance to a QUBO format. - This is a **Driver API** for QUBO conversion calling single-purpose methods in order: - - 1. Convert the instance to a minimization problem by {meth}`~ommx.Instance.as_minimization_problem`. - 2. Check continuous variables and raise error if exists. - 3. Convert inequality constraints - - * Try {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack` first with given ``inequality_integer_slack_max_range``. - * If failed, {meth}`~ommx.Instance.add_integer_slack_to_inequality` - - 4. Convert to QUBO with (uniform) penalty method - - * If ``penalty_weights`` is given (in ``dict[constraint_id, weight]`` form), use {meth}`~ommx.Instance.penalty_method` with the given weights. - * If ``uniform_penalty_weight`` is given, use {meth}`~ommx.Instance.uniform_penalty_method` with the given weight. - * If both are None, defaults to ``uniform_penalty_weight = 1.0``. - - 5. Log-encode integer variables by {meth}`~ommx.Instance.log_encode`. - 6. Finally convert to QUBO format by {meth}`~ommx.Instance.as_qubo_format`. - - Please see the document of each method for details. - If you want to customize the conversion, use the methods above manually. - - # Examples + # Postconditions - Let's consider a maximization problem with two integer variables $x_0, x_1 \in [0, 2]$ subject to an inequality: + The driver is equivalent to QUBO Preparation followed by active-objective formatting and retains the input output semantics. - $$\max \; x_0 + x_1 \quad \text{s.t.} \quad x_0 + 2 x_1 \leq 3$$ - - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = [DecisionVariable.integer(i, lower=0, upper=2, name="x", subscripts=[i]) for i in range(2)] + >>> import copy + >>> from ommx import DecisionVariable, Instance, InstanceClass, PreparationPolicy, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[(x[0] + 2*x[1] <= 3).set_id(0)], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize ... ) - ``` - - Convert into QUBO format - - ```python - >>> qubo, offset = instance.to_qubo() - >>> qubo - {(3, 3): -6.0, (3, 4): 2.0, (3, 5): 4.0, (3, 6): 4.0, (3, 7): 2.0, (3, 8): 4.0, (4, 4): -6.0, (4, 5): 4.0, (4, 6): 4.0, (4, 7): 2.0, (4, 8): 4.0, (5, 5): -9.0, (5, 6): 8.0, (5, 7): 4.0, (5, 8): 8.0, (6, 6): -9.0, (6, 7): 4.0, (6, 8): 8.0, (7, 7): -5.0, (7, 8): 4.0, (8, 8): -8.0} - >>> offset - 9.0 - ``` - - For the maximization problem, the sense is converted to minimization for generating QUBO, and then converted back to maximization. - - ```python - >>> instance.sense == Instance.MAXIMIZE - True - ``` + >>> explicit = copy.copy(instance) + >>> policy = PreparationPolicy.for_qubo(uniform_penalty_weight=2.0) + >>> _ = explicit.prepare(InstanceClass.qubo(), policy) + >>> expected = explicit.as_qubo_format() + >>> actual = instance.to_qubo(uniform_penalty_weight=2.0) + >>> assert actual == expected + >>> assert InstanceClass.qubo().contains(instance) + >>> assert instance.sense == Sense.Minimize + >>> assert instance.objective.evaluate({0: 0}) == 2.0 + >>> solution = instance.evaluate({0: 0}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0) + + # Errors + + Mutually exclusive penalty options raise ``ValueError`` before mutating the instance. + + >>> unchanged = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize + ... ) + >>> before = unchanged.to_v2_bytes() + >>> try: + ... unchanged.to_qubo(uniform_penalty_weight=1.0, penalty_weights={7: 2.0}) + ... except ValueError: + ... pass + ... else: + ... raise AssertionError("mutually exclusive penalty options were accepted") + >>> assert unchanged.to_v2_bytes() == before """ def to_hubo( self, @@ -3464,31 +3501,64 @@ class Instance: r""" Convert the instance to a HUBO format. - This is a **Driver API** for HUBO conversion calling single-purpose methods in order: - - 1. Convert the instance to a minimization problem by {meth}`~ommx.Instance.as_minimization_problem`. - 2. Check continuous variables and raise error if exists. - 3. Convert inequality constraints + # Postconditions - * Try {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack` first with given ``inequality_integer_slack_max_range``. - * If failed, {meth}`~ommx.Instance.add_integer_slack_to_inequality` + The driver is equivalent to HUBO Preparation followed by active-objective formatting and retains the input output semantics. - 4. Convert to HUBO with (uniform) penalty method + >>> import copy + >>> from ommx import DecisionVariable, Instance, InstanceClass, PreparationPolicy, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize + ... ) + >>> explicit = copy.copy(instance) + >>> policy = PreparationPolicy.for_hubo(uniform_penalty_weight=2.0) + >>> _ = explicit.prepare(InstanceClass.hubo(), policy) + >>> expected = explicit.as_hubo_format() + >>> actual = instance.to_hubo(uniform_penalty_weight=2.0) + >>> assert actual == expected + >>> assert InstanceClass.hubo().contains(instance) + >>> assert instance.sense == Sense.Minimize + >>> assert instance.objective.evaluate({0: 0}) == 2.0 + >>> solution = instance.evaluate({0: 0}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0) + + # Errors + + Mutually exclusive penalty options raise ``ValueError`` before mutating the instance. + + >>> unchanged = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize + ... ) + >>> before = unchanged.to_v2_bytes() + >>> try: + ... unchanged.to_hubo(uniform_penalty_weight=1.0, penalty_weights={7: 2.0}) + ... except ValueError: + ... pass + ... else: + ... raise AssertionError("mutually exclusive penalty options were accepted") + >>> assert unchanged.to_v2_bytes() == before + """ + def as_parametric_instance(self) -> ParametricInstance: + r""" + Convert this instance into a parameter-free parametric instance. - * If ``penalty_weights`` is given (in ``dict[constraint_id, weight]`` form), use {meth}`~ommx.Instance.penalty_method` with the given weights. - * If ``uniform_penalty_weight`` is given, use {meth}`~ommx.Instance.uniform_penalty_method` with the given weight. - * If both are None, defaults to ``uniform_penalty_weight = 1.0``. + # Postconditions - 5. Log-encode integer variables by {meth}`~ommx.Instance.log_encode`. - 6. Finally convert to HUBO format by {meth}`~ommx.Instance.as_hubo_format`. + Materializing the result without parameters preserves both active and output objective semantics. - Please see the documentation for {meth}`~ommx.Instance.to_qubo` for more information, or the - documentation for each individual method for additional details. The - difference between this and {meth}`~ommx.Instance.to_qubo` is that this method isn't - restricted to quadratic or linear problems. If you want to customize the - conversion, use the individual methods above manually. + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> restored = instance.as_parametric_instance().with_parameters({}) + >>> assert restored.sense == Sense.Minimize + >>> assert restored.objective.evaluate({0: 1}) == -1.0 + >>> solution = restored.evaluate({0: 1}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0) """ - def as_parametric_instance(self) -> ParametricInstance: ... def penalty_method(self) -> ParametricInstance: r""" Convert to a parametric unconstrained instance by penalty method. @@ -3510,34 +3580,24 @@ class Instance: > This means the penalty is enforced even for $h(x) < 0$ cases, and $h(x) = 0$ is unfairly favored. > This feature is intended to use with {meth}`~ommx.Instance.add_integer_slack_to_inequality`. - # Examples + # Postconditions - ```python - >>> from ommx import Instance, DecisionVariable, Constraint - >>> x = [DecisionVariable.binary(i) for i in range(3)] + Materialization evaluates penalty energy actively while retaining the pre-penalty objective for output and invalidating optimality transport. + + >>> from ommx import DecisionVariable, Instance, Optimality, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[x[0] + x[1] == 1, x[1] + x[2] == 1], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize ... ) - >>> instance.objective - Function(x0 + x1 + x2) - >>> pi = instance.penalty_method() - ``` - - The constraint is put in removed_constraints - - ```python - >>> pi.constraints - [] - >>> len(pi.removed_constraints) - 2 - >>> pi.removed_constraints[0] - RemovedConstraint(x0 + x1 - 1 == 0, reason=ommx.Instance.penalty_method, parameter_id=3) - >>> pi.removed_constraints[1] - RemovedConstraint(x1 + x2 - 1 == 0, reason=ommx.Instance.penalty_method, parameter_id=4) - ``` + >>> parametric = instance.penalty_method() + >>> parameters = {parameter.id: 2.0 for parameter in parametric.parameters} + >>> prepared = parametric.with_parameters(parameters) + >>> assert parametric.constraints == {} + >>> assert 7 in parametric.removed_constraints + >>> assert prepared.objective.evaluate({0: 0}) == 2.0 + >>> solution = prepared.evaluate({0: 0}) + >>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False) + >>> assert prepared.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified """ def uniform_penalty_method(self) -> ParametricInstance: r""" @@ -3559,44 +3619,24 @@ class Instance: > This means the penalty is enforced even for $h(x) < 0$ cases, and $h(x) = 0$ is unfairly favored. > This feature is intended to use with {meth}`~ommx.Instance.add_integer_slack_to_inequality`. - # Examples + # Postconditions - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = [DecisionVariable.binary(i) for i in range(3)] + Materialization evaluates uniform-penalty energy actively while retaining the pre-penalty objective for output and invalidating optimality transport. + + >>> from ommx import DecisionVariable, Instance, Optimality, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[sum(x) == 3], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize ... ) - >>> instance.objective - Function(x0 + x1 + x2) - >>> pi = instance.uniform_penalty_method() - ``` - - The constraint is put in removed_constraints - - ```python - >>> pi.constraints - [] - >>> len(pi.removed_constraints) - 1 - >>> pi.removed_constraints[0] - RemovedConstraint(x0 + x1 + x2 - 3 == 0, reason=ommx.Instance.uniform_penalty_method) - ``` - - There is only one parameter in the instance - - ```python - >>> len(pi.parameters) - 1 - >>> p = pi.parameters[0] - >>> p.id - 3 - >>> p.name - 'uniform_penalty_weight' - ``` + >>> parametric = instance.uniform_penalty_method() + >>> parameter_id = parametric.parameters[0].id + >>> prepared = parametric.with_parameters({parameter_id: 2.0}) + >>> assert parametric.constraints == {} + >>> assert 7 in parametric.removed_constraints + >>> assert prepared.objective.evaluate({0: 0}) == 2.0 + >>> solution = prepared.evaluate({0: 0}) + >>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False) + >>> assert prepared.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified """ def evaluate( self, state: ToState, *, atol: typing.Optional[builtins.float] = None @@ -3604,48 +3644,33 @@ class Instance: r""" Evaluate the given {class}`~ommx.State` into a {class}`~ommx.Solution`. - This method evaluates the problem instance using the provided state (a map from decision variable IDs to their values), - and returns a {class}`~ommx.Solution` object containing objective value, evaluated constraint values, and feasibility information. + # Postconditions - # Examples - - Create a simple instance with three binary variables and evaluate a solution: + Evaluation populates the full state before applying preserved output objective semantics. - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = [DecisionVariable.binary(i) for i in range(3)] + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[(x[0] + x[1] <= 1).set_id(0)], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize ... ) - ``` + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> fixed = instance.partial_evaluate({0: 1}) + >>> solution = fixed.evaluate({}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) - Evaluate it with a state x0 = 1, x1 = 0, x2 = 0, and show the objective and constraints: + # Errors - ```python - >>> solution = instance.evaluate({0: 1, 1: 0, 2: 0}) - >>> solution.objective - 1.0 - ``` - - If the value is out of the range, the solution is infeasible: - - ```python - >>> solution = instance.evaluate({0: 1, 1: 0, 2: 2}) - >>> solution.feasible - False - ``` - - If some of the decision variables are not set, this raises an error: + Evaluation raises ``ValueError`` when an active required ID is missing. - ```python - >>> instance.evaluate({0: 1, 1: 0}) - ``` - Traceback (most recent call last): - ... - ValueError: state is missing required variable IDs: {VariableID(2)} + >>> required = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize + ... ) + >>> try: + ... required.evaluate({}) + ... except ValueError as error: + ... assert "missing required variable IDs" in str(error) + ... else: + ... raise AssertionError("evaluation accepted a missing active ID") """ def populate_state( self, state: ToState, *, atol: typing.Optional[builtins.float] = None @@ -3656,6 +3681,20 @@ class Instance: The input state must contain all decision variables that are actually used by this instance's objective and active constraints. The returned {class}`~ommx.State` contains every decision variable in the instance. + + # Postconditions + + The returned state restores fixed variables needed only by preserved output semantics. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> fixed = instance.partial_evaluate({0: 1}) + >>> assert fixed.populate_state({}).entries == {0: 1.0} + >>> assert fixed.evaluate({}).objective == 3.0 """ def partial_evaluate( self, state: ToState, *, atol: typing.Optional[builtins.float] = None @@ -3679,35 +3718,46 @@ class Instance: **Returns:** A new instance with the specified decision variables fixed to their given values. - # Examples + # Postconditions - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = DecisionVariable.binary(1) - >>> y = DecisionVariable.binary(2) + The new instance rewrites only active expressions while retaining fixed values for output evaluation. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=[x, y], - ... objective=x + y, - ... constraints=[x + y <= 1], - ... sense=Instance.MINIMIZE + ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize ... ) - >>> new_instance = instance.partial_evaluate({1: 1}) - >>> new_instance.objective - Function(x2 + 1) - ``` - - Fixed values are owned by the instance and exposed through the - attached decision-variable view: - - ```python - >>> x = new_instance.attached_decision_variable(1) - >>> x.substituted_value - 1.0 - ``` + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> fixed = instance.partial_evaluate({0: 1}) + >>> assert instance.required_ids() == {0} + >>> assert fixed.required_ids() == set() + >>> assert fixed.objective.evaluate({}) == -3.0 + >>> assert fixed.attached_decision_variable(0).substituted_value == 1.0 + >>> solution = fixed.evaluate({}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) """ def evaluate_samples( self, samples: ToSamples, *, atol: typing.Optional[builtins.float] = None - ) -> SampleSet: ... + ) -> SampleSet: + r""" + Evaluate samples into a sample set. + + # Postconditions + + Every sample restores fixed variables before applying preserved output objective semantics. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> fixed = instance.partial_evaluate({0: 1}) + >>> sample_set = fixed.evaluate_samples({7: {}}) + >>> assert sample_set.sense == Sense.Maximize + >>> assert sample_set.objectives[7] == 3.0 + >>> assert sample_set.get(7).state.entries == {0: 1.0} + """ def random_state(self, rng: Rng) -> State: r""" Generate a random state for this instance using the provided random number generator. @@ -3727,33 +3777,27 @@ class Instance: Generate random state only for used variables - ```python - >>> from ommx import Instance, DecisionVariable, Rng + >>> from ommx import DecisionVariable, Instance, Rng, Sense >>> x = [DecisionVariable.binary(i) for i in range(5)] >>> instance = Instance.from_components( ... decision_variables=x, ... objective=x[0] + x[1], - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... constraints={}, + ... sense=Sense.Maximize, ... ) >>> rng = Rng() >>> state = instance.random_state(rng) - ``` Only used variables have values - ```python >>> set(state.entries.keys()) {0, 1} - ``` Values respect binary bounds - ```python >>> all(state.entries[i] in [0.0, 1.0] for i in state.entries) True - ``` """ def random_samples( self, @@ -3787,21 +3831,19 @@ class Instance: Generate samples for a simple instance: - ```python - >>> from ommx import Instance, DecisionVariable, Rng + >>> from ommx import DecisionVariable, Instance, Rng, Sense >>> x = [DecisionVariable.binary(i) for i in range(3)] >>> instance = Instance.from_components( ... decision_variables=x, ... objective=sum(x), - ... constraints=[(sum(x) <= 2).set_id(0)], - ... sense=Instance.MAXIMIZE, + ... constraints={0: sum(x) <= 2}, + ... sense=Sense.Maximize, ... ) >>> rng = Rng() >>> samples = instance.random_samples(rng, num_different_samples=2, num_samples=5) >>> samples.num_samples() 5 - ``` """ def relax_constraint( self, constraint_id: builtins.int, reason: builtins.str, **parameters: str @@ -3820,34 +3862,23 @@ class Instance: Relax constraint, and restore it. - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i) for i in range(3)] >>> instance = Instance.from_components( ... decision_variables=x, ... objective=sum(x), - ... constraints=[(sum(x) == 3).set_id(1)], - ... sense=Instance.MAXIMIZE, + ... constraints={1: sum(x) == 3}, + ... sense=Sense.Maximize, ... ) - >>> instance.constraints - [Constraint(x0 + x1 + x2 - 3 == 0)] - ``` + >>> assert set(instance.constraints) == {1} - ```python >>> instance.relax_constraint(1, "manual relaxation") - >>> instance.constraints - [] - >>> instance.removed_constraints - [RemovedConstraint(x0 + x1 + x2 - 3 == 0, reason=manual relaxation)] - ``` + >>> assert not instance.constraints + >>> assert set(instance.removed_constraints) == {1} - ```python >>> instance.restore_constraint(1) - >>> instance.constraints - [Constraint(x0 + x1 + x2 - 3 == 0)] - >>> instance.removed_constraints - [] - ``` + >>> assert set(instance.constraints) == {1} + >>> assert not instance.removed_constraints """ def restore_constraint(self, constraint_id: builtins.int) -> None: ... def relax_indicator_constraint( @@ -3875,7 +3906,6 @@ class Instance: # Examples - ```python >>> from ommx import Instance, DecisionVariable, OneHotConstraint >>> x = [DecisionVariable.binary(i) for i in range(3)] >>> instance = Instance.from_components( @@ -3892,7 +3922,6 @@ class Instance: {0: Constraint(x0 + x1 + x2 - 1 == 0)} >>> instance.removed_one_hot_constraints {1: RemovedOneHotConstraint(OneHotConstraint(exactly one of {x0, x1, x2} = 1), reason=ommx.Instance.convert_one_hot_to_constraint, constraint_id=0)} - ``` """ def convert_all_one_hots_to_constraints(self) -> builtins.list[builtins.int]: r""" @@ -3903,7 +3932,6 @@ class Instance: # Examples - ```python >>> from ommx import Instance, DecisionVariable, OneHotConstraint >>> x = [DecisionVariable.binary(i) for i in range(4)] >>> instance = Instance.from_components( @@ -3922,7 +3950,6 @@ class Instance: {} >>> instance.constraints {0: Constraint(x0 + x1 - 1 == 0), 1: Constraint(x2 + x3 - 1 == 0)} - ``` """ def convert_sos1_to_constraints( self, sos1_id: builtins.int @@ -3962,7 +3989,6 @@ class Instance: All-binary SOS1 reduces to ``sum(x_i) - 1 <= 0`` without extra variables: - ```python >>> from ommx import Instance, DecisionVariable, Sos1Constraint >>> x = [DecisionVariable.binary(i) for i in range(3)] >>> instance = Instance.from_components( @@ -3980,7 +4006,6 @@ class Instance: {0: Constraint(x0 + x1 + x2 - 1 <= 0)} >>> instance.removed_sos1_constraints {1: RemovedSos1Constraint(Sos1Constraint(at most one of {x0, x1, x2} ≠ 0), reason=ommx.Instance.convert_sos1_to_constraints, constraint_ids=0)} - ``` """ def convert_all_sos1_to_constraints( self, @@ -3999,7 +4024,6 @@ class Instance: # Examples - ```python >>> from ommx import Instance, DecisionVariable, Sos1Constraint >>> x = [DecisionVariable.binary(i) for i in range(4)] >>> instance = Instance.from_components( @@ -4018,7 +4042,6 @@ class Instance: {} >>> instance.constraints {0: Constraint(x0 + x1 - 1 <= 0), 1: Constraint(x2 + x3 - 1 <= 0)} - ``` """ def convert_indicator_to_constraint( self, indicator_id: builtins.int @@ -4065,7 +4088,6 @@ class Instance: Convert an inequality indicator where the upper side is active: - ```python >>> from ommx import ( ... Instance, DecisionVariable, IndicatorConstraint, Equality, ... ) @@ -4089,7 +4111,6 @@ class Instance: {} >>> instance.constraints {0: Constraint(x0 + 3*x1 - 5 <= 0)} - ``` """ def convert_all_indicators_to_constraints( self, @@ -4131,42 +4152,23 @@ class Instance: unavailable for a requested variable. Allocation and expression-rewrite failures retain their original exception types. - # Examples + # Postconditions - Let's consider a simple integer programming problem with three integer variables x0, x1, and x2. + Encoding rewrites the active objective while output evaluation restores the encoded integer value. - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = [ - ... DecisionVariable.integer(i, lower=0, upper=3, name="x", subscripts=[i]) - ... for i in range(3) - ... ] + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.integer(0, lower=0, upper=3) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize ... ) - >>> instance.objective - Function(x0 + x1 + x2) - ``` - - To log-encode the integer variables x0 and x2 (except x1), call log_encode: - - ```python - >>> instance.log_encode({0, 2}) - ``` - - Integer variable in range $[0, 3]$ can be represented by two binary variables: - - $$x_0 = b_{0,0} + 2 b_{0,1}, \quad x_2 = b_{2,0} + 2 b_{2,1}$$ - - And these are substituted into the objective and constraint functions. - - ```python - >>> instance.objective - Function(x1 + x3 + 2*x4 + x5 + 2*x6) - ``` + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> instance.log_encode({0}) + >>> encoded_ids = instance.required_ids() + >>> assert len(encoded_ids) == 2 + >>> state = {variable_id: 1 for variable_id in encoded_ids} + >>> assert instance.objective.evaluate(state) == -3.0 + >>> solution = instance.evaluate(state) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) """ def unary_encode( self, @@ -4197,21 +4199,23 @@ class Instance: - `atol`: Optional absolute tolerance used when normalizing integer bounds before encoding. If None, uses the default tolerance. - # Examples + # Postconditions - ```python - >>> from ommx import Instance, DecisionVariable + Encoding rewrites the active objective while output evaluation restores the encoded integer value. + + >>> from ommx import DecisionVariable, Instance, Sense >>> x = DecisionVariable.integer(0, lower=2, upper=5, name="x") >>> instance = Instance.from_components( - ... decision_variables=[x], - ... objective=x, - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) >>> instance.unary_encode({0}) - >>> instance.objective - Function(x1 + x2 + x3 + 2) - ``` + >>> encoded_ids = instance.required_ids() + >>> assert len(encoded_ids) == 3 + >>> state = {variable_id: 1 for variable_id in encoded_ids} + >>> assert instance.objective.evaluate(state) == -5.0 + >>> solution = instance.evaluate(state) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 5.0) """ def substitute(self, assignments: typing.Mapping[builtins.int, ToFunction]) -> None: r""" @@ -4240,25 +4244,22 @@ class Instance: substituting a variable that is a member of an indicator, one-hot, or SOS1 constraint. - # Examples + # Postconditions - Encode an integer variable x0 in range $[0, 3]$ into two binary - variables by hand, instead of using {meth}`~ommx.Instance.log_encode`: + Substitution rewrites the active objective while output evaluation restores the substituted variable value. - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = DecisionVariable.integer(0, lower=0, upper=3, name="x") - >>> b = [DecisionVariable.binary(i, name="b", subscripts=[i]) for i in (1, 2)] + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> b = DecisionVariable.binary(1) >>> instance = Instance.from_components( - ... decision_variables=[x, *b], - ... objective=x, - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x, b], objective=x, constraints={}, sense=Sense.Maximize ... ) - >>> instance.substitute({0: b[0] + 2 * b[1]}) - >>> instance.objective - Function(x1 + 2*x2) - ``` + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> instance.substitute({0: b}) + >>> assert instance.required_ids() == {1} + >>> assert instance.objective.evaluate({1: 1}) == -1.0 + >>> solution = instance.evaluate({1: 1}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0) """ def convert_inequality_to_equality_with_integer_slack( self, constraint_id: builtins.int, max_integer_range: builtins.int @@ -4285,8 +4286,7 @@ class Instance: Let's consider a simple inequality constraint x0 + 2*x1 <= 5. - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Equality, Instance, Sense >>> x = [ ... DecisionVariable.integer(i, lower=0, upper=3, name="x", subscripts=[i]) ... for i in range(3) @@ -4294,25 +4294,20 @@ class Instance: >>> instance = Instance.from_components( ... decision_variables=x, ... objective=sum(x), - ... constraints=[ - ... (x[0] + 2*x[1] <= 5).set_id(0) - ... ], - ... sense=Instance.MAXIMIZE, + ... constraints={0: x[0] + 2*x[1] <= 5}, + ... sense=Sense.Maximize, ... ) - >>> instance.constraints[0] - Constraint(x0 + 2*x1 - 5 <= 0) - ``` Introduce an integer slack variable - ```python >>> instance.convert_inequality_to_equality_with_integer_slack( ... constraint_id=0, ... max_integer_range=32 ... ) - >>> instance.constraints[0] - Constraint(x0 + 2*x1 + x3 - 5 == 0) - ``` + >>> assert instance.constraints[0].function.terms == { + ... (0,): 1.0, (1,): 2.0, (3,): 1.0, (): -5.0 + ... } + >>> assert instance.constraints[0].equality == Equality.EqualToZero Raises {class}`~ommx.ExactIntegerSlackError` when exact conversion is unavailable because the coefficients cannot be normalized or the slack @@ -4343,8 +4338,7 @@ class Instance: Let's consider a simple inequality constraint x0 + 2*x1 <= 4. - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Equality, Instance, Sense >>> x = [ ... DecisionVariable.integer(i, lower=0, upper=3, name="x", subscripts=[i]) ... for i in range(3) @@ -4352,25 +4346,21 @@ class Instance: >>> instance = Instance.from_components( ... decision_variables=x, ... objective=sum(x), - ... constraints=[ - ... (x[0] + 2*x[1] <= 4).set_id(0) - ... ], - ... sense=Instance.MAXIMIZE, + ... constraints={0: x[0] + 2*x[1] <= 4}, + ... sense=Sense.Maximize, ... ) - >>> instance.constraints[0] - Constraint(x0 + 2*x1 - 4 <= 0) - ``` Introduce an integer slack variable s in [0, 2] - ```python >>> b = instance.add_integer_slack_to_inequality( ... constraint_id=0, ... slack_upper_bound=2 ... ) - >>> b, instance.constraints[0] - (2.0, Constraint(x0 + 2*x1 + 2*x3 - 4 <= 0)) - ``` + >>> assert b == 2.0 + >>> assert instance.constraints[0].function.terms == { + ... (0,): 1.0, (1,): 2.0, (3,): 2.0, (): -4.0 + ... } + >>> assert instance.constraints[0].equality == Equality.LessThanOrEqualToZero """ def decision_variable_role( self, id: builtins.int @@ -4439,7 +4429,6 @@ class Instance: # Examples - ```python >>> from ommx import Instance >>> instance = Instance.minimize() >>> stats = instance.stats() @@ -4447,7 +4436,6 @@ class Instance: 0 >>> stats["constraints"]["total"] 0 - ``` """ def decision_variables_df( self, include: typing.Optional[typing.Sequence[builtins.str]] = None @@ -4549,89 +4537,120 @@ class Instance: r""" Convert the instance to a minimization problem. - If the instance is already a minimization problem, this does nothing. + If both the active objective and the output objective already use + minimization, this does nothing. **Returns:** - ``True`` if the instance is converted, ``False`` if already a minimization problem. + ``True`` if either objective is converted, ``False`` if both already + use minimization. - # Examples + # Postconditions - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = [DecisionVariable.binary(i) for i in range(3)] + Conversion changes both active and output objective semantics and is idempotent at the target sense. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[sum(x) == 1], - ... sense=Instance.MAXIMIZE, + ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize ... ) - >>> instance.sense == Instance.MAXIMIZE - True - >>> instance.objective - Function(x0 + x1 + x2) - ``` - - Convert to a minimization problem - - ```python - >>> instance.as_minimization_problem() - True - >>> instance.sense == Instance.MINIMIZE - True - >>> instance.objective - Function(-x0 - x1 - x2) - ``` - - If the instance is already a minimization problem, this does nothing - - ```python - >>> instance.as_minimization_problem() - False - ``` + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> assert instance.evaluate({0: 1}).objective == 3.0 + >>> assert instance.as_minimization_problem() + >>> solution = instance.evaluate({0: 1}) + >>> assert instance.objective.evaluate({0: 1}) == -3.0 + >>> assert (solution.sense, solution.objective) == (Sense.Minimize, -3.0) + >>> assert not instance.as_minimization_problem() """ def as_maximization_problem(self) -> builtins.bool: r""" Convert the instance to a maximization problem. - If the instance is already a maximization problem, this does nothing. + If both the active objective and the output objective already use + maximization, this does nothing. **Returns:** - ``True`` if the instance is converted, ``False`` if already a maximization problem. + ``True`` if either objective is converted, ``False`` if both already + use maximization. - # Examples + # Postconditions - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = [DecisionVariable.binary(i) for i in range(3)] + Conversion changes both active and output objective semantics and is idempotent at the target sense. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[sum(x) == 1], - ... sense=Instance.MINIMIZE, + ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Minimize ... ) - >>> instance.sense == Instance.MINIMIZE - True - >>> instance.objective - Function(x0 + x1 + x2) - ``` + >>> assert instance.convert_active_objective(Sense.Maximize) + >>> assert instance.evaluate({0: 1}).objective == 3.0 + >>> assert instance.as_maximization_problem() + >>> solution = instance.evaluate({0: 1}) + >>> assert instance.objective.evaluate({0: 1}) == -3.0 + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, -3.0) + >>> assert not instance.as_maximization_problem() + """ + def convert_active_objective(self, target: Sense) -> builtins.bool: + r""" + Convert only the active objective used by a solver-facing formulation. + + This changes {attr}`~ommx.Instance.sense` and + {attr}`~ommx.Instance.objective` to ``target`` while preserving the + objective semantics returned by {meth}`~ommx.Instance.evaluate` and + {meth}`~ommx.Instance.evaluate_samples`. Use + {meth}`~ommx.Instance.as_minimization_problem` or + {meth}`~ommx.Instance.as_maximization_problem` when the output objective + should be converted as part of the mathematical problem itself. - Convert to a maximization problem + **Returns:** + ``True`` if the active objective is converted, ``False`` if it already + has ``target``. - ```python - >>> instance.as_maximization_problem() - True - >>> instance.sense == Instance.MAXIMIZE - True - >>> instance.objective - Function(-x0 - x1 - x2) - ``` + # Postconditions - If the instance is already a maximization problem, this does nothing + Conversion negates only the active objective and preserves evaluation semantics in either direction. - ```python - >>> instance.as_maximization_problem() - False - ``` + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> for source, target in ((Sense.Maximize, Sense.Minimize), (Sense.Minimize, Sense.Maximize)): + ... instance = Instance.from_components( + ... decision_variables=[x], objective=3 * x, constraints={}, sense=source + ... ) + ... before = instance.evaluate({0: 1}) + ... assert instance.convert_active_objective(target) + ... after = instance.evaluate({0: 1}) + ... assert instance.sense == target + ... assert instance.objective.evaluate({0: 1}) == -3.0 + ... assert (after.sense, after.objective) == (before.sense, before.objective) + ... assert not instance.convert_active_objective(target) + """ + def map_active_optimality(self, active: Optimality) -> Optimality: + r""" + Map an optimality status for the active solver-facing formulation to + the objective semantics returned by evaluation. + + When the instance records that active-formulation optimality does not + transport to its output objective, this returns + {attr}`~ommx.Optimality.Unspecified`. + + # Postconditions + + Optimality is preserved for equivalent objective conversion and discarded after penalty preparation. + + >>> from ommx import DecisionVariable, Instance, Optimality, Sense + >>> x = DecisionVariable.binary(0) + >>> equivalent = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert equivalent.convert_active_objective(Sense.Minimize) + >>> statuses = (Optimality.Unspecified, Optimality.Optimal, Optimality.NotOptimal) + >>> for status in statuses: + ... assert equivalent.map_active_optimality(status) == status + >>> penalized = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize + ... ) + >>> _ = penalized.to_qubo(uniform_penalty_weight=1.0) + >>> for status in statuses: + ... assert penalized.map_active_optimality(status) == Optimality.Unspecified """ def get_decision_variable_by_id( self, variable_id: builtins.int @@ -4665,40 +4684,21 @@ class Instance: **Returns:** ``True`` if any reduction was performed, ``False`` otherwise. - # Examples + # Postconditions - Consider an instance with binary variables and quadratic terms: + Reduction simplifies only active expressions while preserving output evaluation semantics. - ```python - >>> from ommx import Instance, DecisionVariable - >>> x = [DecisionVariable.binary(i) for i in range(2)] + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=x[0] * x[0] + x[0] * x[1], - ... constraints=[], - ... sense=Instance.MINIMIZE, + ... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Maximize ... ) - >>> instance.objective - Function(x0*x0 + x0*x1) - ``` - - After reducing binary powers, x0^2 becomes x0: - - ```python - >>> changed = instance.reduce_binary_power() - >>> changed - True - >>> instance.objective - Function(x0*x1 + x0) - ``` - - Running it again should not change anything: - - ```python - >>> changed = instance.reduce_binary_power() - >>> changed - False - ``` + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> assert instance.reduce_binary_power() + >>> assert instance.objective.evaluate({0: 1}) == -1.0 + >>> solution = instance.evaluate({0: 1}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0) + >>> assert not instance.reduce_binary_power() """ @staticmethod def load_mps(path: builtins.str) -> Instance: ... @@ -4727,19 +4727,17 @@ class Instance: # Examples - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i) for i in range(3)] >>> instance = Instance.from_components( ... decision_variables=x, ... objective=x[0] + x[1], - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... constraints={}, + ... sense=Sense.Maximize, ... ) >>> profile = instance.logical_memory_profile() >>> isinstance(profile, str) True - ``` """ def prepare(self, input_class: InstanceClass, policy: PreparationPolicy) -> None: r""" @@ -4751,18 +4749,38 @@ class Instance: 1. ``special_constraints``: {meth}`~ommx.Instance.lower_special_constraints` - 2. ``sense``: {meth}`~ommx.Instance.as_minimization_problem` + 2. ``objective``: {meth}`~ommx.Instance.convert_active_objective` 3. ``integer_slack``: {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack`, followed by {meth}`~ommx.Instance.add_integer_slack_to_inequality` only when exact conversion is unavailable and ``slack_upper_bound`` is set - 4. ``integer_encoding``: {meth}`~ommx.Instance.log_encode` - 5. ``fixed_penalty`` + 4. ``fixed_penalty`` + 5. ``integer_encoding``: {meth}`~ommx.Instance.log_encode` + 6. ``binary_power_reduction``: + {meth}`~ommx.Instance.reduce_binary_power` Success guarantees membership only, not Adapter applicability. This operation is not transactional, so an error may leave the instance changed. {class}`~ommx.PreparationTargetNotReachedError` exposes the final membership report when the selections do not reach ``input_class``. + + # Postconditions + + Successful Preparation mutates the owner into the target class while preserving output evaluation semantics. + + >>> from ommx import DecisionVariable, Instance, InstanceClass, Optimality, PreparationPolicy, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize + ... ) + >>> policy = PreparationPolicy.for_qubo(uniform_penalty_weight=2.0) + >>> assert instance.prepare(InstanceClass.qubo(), policy) is None + >>> assert InstanceClass.qubo().contains(instance) + >>> assert instance.sense == Sense.Minimize + >>> assert instance.objective.evaluate({0: 0}) == 2.0 + >>> solution = instance.evaluate({0: 0}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0) + >>> assert instance.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified """ @typing.final @@ -4775,6 +4793,80 @@ class InstanceClass: def __new__( cls, clauses: typing.Sequence[InstanceClassClause] ) -> InstanceClass: ... + @staticmethod + def qubo() -> InstanceClass: + r""" + Class of minimization QUBO formulations accepted by + {meth}`~ommx.Instance.as_qubo_format` after Preparation. + + # Postconditions + + The target accepts unconstrained minimization QUBO formulations and rejects models outside that class. + + >>> from ommx import DecisionVariable, Instance, InstanceClass, Sense + >>> x = DecisionVariable.binary(0) + >>> linear = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize + ... ) + >>> quadratic = Instance.from_components( + ... decision_variables=[x], objective=x * x, constraints={}, sense=Sense.Minimize + ... ) + >>> cubic = Instance.from_components( + ... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Minimize + ... ) + >>> maximizing = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> continuous = DecisionVariable.continuous(1) + >>> non_binary = Instance.from_components( + ... decision_variables=[continuous], objective=continuous, constraints={}, sense=Sense.Minimize + ... ) + >>> constrained = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={0: x == 1}, sense=Sense.Minimize + ... ) + >>> target = InstanceClass.qubo() + >>> assert target.contains(linear) + >>> assert target.contains(quadratic) + >>> assert not target.contains(cubic) + >>> assert not target.contains(maximizing) + >>> assert not target.contains(non_binary) + >>> assert not target.contains(constrained) + """ + @staticmethod + def hubo() -> InstanceClass: + r""" + Class of minimization HUBO formulations accepted by + {meth}`~ommx.Instance.as_hubo_format` after Preparation. + + # Postconditions + + The target accepts unconstrained minimization Binary HUBO formulations and rejects models outside that class. + + >>> from ommx import DecisionVariable, Instance, InstanceClass, Sense + >>> x = DecisionVariable.binary(0) + >>> linear = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize + ... ) + >>> cubic = Instance.from_components( + ... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Minimize + ... ) + >>> maximizing = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> continuous = DecisionVariable.continuous(1) + >>> non_binary = Instance.from_components( + ... decision_variables=[continuous], objective=continuous, constraints={}, sense=Sense.Minimize + ... ) + >>> constrained = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={0: x == 1}, sense=Sense.Minimize + ... ) + >>> target = InstanceClass.hubo() + >>> assert target.contains(linear) + >>> assert target.contains(cubic) + >>> assert not target.contains(maximizing) + >>> assert not target.contains(non_binary) + >>> assert not target.contains(constrained) + """ def union(self, other: InstanceClass) -> InstanceClass: r""" Return the finite union of two instance classes. @@ -5112,30 +5204,22 @@ class Linear: Create a linear function `f(x₁, x₂) = 2x₁ + 3x₂ + 1`: - ```python >>> f = Linear(terms={1: 2, 2: 3}, constant=1) - ``` Or create via DecisionVariable arithmetic: - ```python >>> x1 = DecisionVariable.integer(1) >>> x2 = DecisionVariable.integer(2) >>> g = 2*x1 + 3*x2 + 1 - ``` Compare two linear functions with tolerance: - ```python >>> f.almost_equal(g, atol=1e-12) True - ``` Note that `==` creates an equality Constraint, not a boolean: - ```python >>> constraint = f == g # Returns Constraint, not bool - ``` """ @property def linear_terms(self) -> builtins.dict[builtins.int, builtins.float]: ... @@ -5388,6 +5472,24 @@ class NamedFunction: def __copy__(self) -> NamedFunction: ... def __deepcopy__(self, _memo: typing.Any) -> NamedFunction: ... +@typing.final +class ObjectivePreparation: + r""" + Convert the active objective to ``target`` during Preparation. + + # Invariants + + The immutable target records the solver-facing sense requested by Preparation. + + >>> from ommx import ObjectivePreparation, Sense + >>> preparation = ObjectivePreparation(target=Sense.Minimize) + >>> assert preparation.target == Sense.Minimize + """ + @property + def target(self) -> Sense: ... + def __eq__(self, other: builtins.object, /) -> builtins.bool: ... + def __new__(cls, *, target: Sense) -> ObjectivePreparation: ... + @typing.final class OneHotConstraint: r""" @@ -5525,12 +5627,10 @@ class Parameter: # Examples - ```python >>> p = Parameter(1, name="penalty") >>> x = DecisionVariable.integer(2) >>> x + p # Returns Linear expression Linear(...) - ``` """ @property def id(self) -> builtins.int: ... @@ -5791,8 +5891,50 @@ class ParametricInstance: def from_v1_bytes(bytes: bytes) -> ParametricInstance: ... @staticmethod def from_v2_bytes(bytes: bytes) -> ParametricInstance: ... - def to_v1_bytes(self) -> bytes: ... - def to_v2_bytes(self) -> bytes: ... + def to_v1_bytes(self) -> bytes: + r""" + Serialize this parametric instance in the OMMX v1 wire format. + + # Errors + + Serialization raises ``RuntimeError`` when distinct output semantics cannot be represented by v1. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> instance = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert instance.convert_active_objective(Sense.Minimize) + >>> parametric = instance.as_parametric_instance() + >>> try: + ... parametric.to_v1_bytes() + ... except RuntimeError: + ... pass + ... else: + ... raise AssertionError("v1 serialization accepted distinct output semantics") + """ + def to_v2_bytes(self) -> bytes: + r""" + Serialize this parametric instance in the OMMX v2 wire format. + + # Postconditions + + A v2 round-trip preserves both active and output objective semantics through materialization. + + >>> from ommx import DecisionVariable, Instance, ParametricInstance, Sense + >>> x = DecisionVariable.binary(0) + >>> source = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize + ... ) + >>> parametric = source.uniform_penalty_method() + >>> parameter_id = parametric.parameters[0].id + >>> restored = ParametricInstance.from_v2_bytes(parametric.to_v2_bytes()) + >>> materialized = restored.with_parameters({parameter_id: 2.0}) + >>> assert materialized.sense == Sense.Minimize + >>> assert materialized.objective.evaluate({0: 0}) == 2.0 + >>> solution = materialized.evaluate({0: 0}) + >>> assert (solution.sense, solution.objective) == (Sense.Minimize, 0.0) + """ def __str__(self) -> builtins.str: ... def __repr__(self) -> builtins.str: ... @staticmethod @@ -5833,6 +5975,22 @@ class ParametricInstance: Substitute parameters to yield an instance. Parameters can be provided as a dict mapping parameter IDs to their values. + + # Postconditions + + Materialization substitutes parameters in active energy while retaining the pre-penalty objective for output evaluation. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> source = Instance.from_components( + ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize + ... ) + >>> parametric = source.uniform_penalty_method() + >>> parameter_id = parametric.parameters[0].id + >>> materialized = parametric.with_parameters({parameter_id: 2.0}) + >>> assert materialized.objective.evaluate({0: 0}) == 2.0 + >>> solution = materialized.evaluate({0: 0}) + >>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False) """ def format_function( self, @@ -5887,6 +6045,25 @@ class ParametricInstance: IDs, when a parameter ID is used as an assignment target, or when substituting a variable that is a member of an indicator, one-hot, or SOS1 constraint. + + # Postconditions + + Substitution rewrites active expressions while materialized output evaluation restores the substituted variable. + + >>> from ommx import DecisionVariable, Instance, Sense + >>> x = DecisionVariable.binary(0) + >>> b = DecisionVariable.binary(1) + >>> source = Instance.from_components( + ... decision_variables=[x, b], objective=x, constraints={}, sense=Sense.Maximize + ... ) + >>> assert source.convert_active_objective(Sense.Minimize) + >>> parametric = source.as_parametric_instance() + >>> parametric.substitute({0: b}) + >>> materialized = parametric.with_parameters({}) + >>> assert materialized.required_ids() == {1} + >>> assert materialized.objective.evaluate({1: 1}) == -1.0 + >>> solution = materialized.evaluate({1: 1}) + >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0) """ def add_decision_variable( self, variable: DecisionVariable @@ -6056,17 +6233,13 @@ class Polynomial: Create via DecisionVariable operations: - ```python >>> x = DecisionVariable.integer(1) >>> y = DecisionVariable.integer(2) >>> p = x * x * y + x * y * y + 1 # Cubic polynomial - ``` Note that `==`, `<=`, `>=` create Constraint objects: - ```python >>> constraint = p == 0 # Returns Constraint - ``` """ @typing.overload def __add__( @@ -6154,6 +6327,24 @@ class Polynomial: @typing.final class PreparationPolicy: + r""" + Select optional transformations applied by {meth}`~ommx.Instance.prepare`. + + # Invariants + + A default policy selects no Preparation phase. + + >>> from ommx import PreparationPolicy + >>> policy = PreparationPolicy() + >>> assert ( + ... policy.special_constraints, + ... policy.objective, + ... policy.integer_slack, + ... policy.integer_encoding, + ... policy.fixed_penalty, + ... policy.binary_power_reduction, + ... ) == (None, None, None, None, None, None) + """ @property def special_constraints(self) -> typing.Optional[SpecialConstraintPreparation]: ... @special_constraints.setter @@ -6161,9 +6352,9 @@ class PreparationPolicy: self, value: typing.Optional[SpecialConstraintPreparation] ) -> None: ... @property - def sense(self) -> typing.Optional[SensePreparation]: ... - @sense.setter - def sense(self, value: typing.Optional[SensePreparation]) -> None: ... + def objective(self) -> typing.Optional[ObjectivePreparation]: ... + @objective.setter + def objective(self, value: typing.Optional[ObjectivePreparation]) -> None: ... @property def integer_slack(self) -> typing.Optional[IntegerSlackPreparation]: ... @integer_slack.setter @@ -6182,16 +6373,150 @@ class PreparationPolicy: def fixed_penalty( self, value: typing.Optional[FixedPenaltyPreparation] ) -> None: ... + @property + def binary_power_reduction(self) -> typing.Optional[BinaryPowerPreparation]: ... + @binary_power_reduction.setter + def binary_power_reduction( + self, value: typing.Optional[BinaryPowerPreparation] + ) -> None: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, *, special_constraints: typing.Optional[SpecialConstraintPreparation] = None, - sense: typing.Optional[SensePreparation] = None, + objective: typing.Optional[ObjectivePreparation] = None, integer_slack: typing.Optional[IntegerSlackPreparation] = None, integer_encoding: typing.Optional[IntegerEncodingPreparation] = None, fixed_penalty: typing.Optional[FixedPenaltyPreparation] = None, + binary_power_reduction: typing.Optional[BinaryPowerPreparation] = None, ) -> PreparationPolicy: ... + @staticmethod + def for_qubo( + *, + uniform_penalty_weight: typing.Optional[builtins.float] = None, + penalty_weights: typing.Optional[ + typing.Mapping[builtins.int, builtins.float] + ] = None, + inequality_integer_slack_max_range: builtins.int = 31, + ) -> PreparationPolicy: + r""" + Return a fresh policy for preparing an instance for QUBO formatting. + + ``uniform_penalty_weight`` and ``penalty_weights`` override the default + uniform penalty weight of 1.0 and are mutually exclusive. The keyed form + must cover exactly the active regular constraints at the penalty phase. + ``inequality_integer_slack_max_range`` defaults to 31 and configures both + the exact Integer-slack range and the fallback slack upper bound. This + QUBO policy also reduces powers of Binary variables before checking the + quadratic target. + + # Postconditions + + Each call returns a fresh complete QUBO policy whose optional weights and slack range are applied exactly. + + >>> from ommx import ( + ... BinaryPowerPreparation, FixedPenaltyPreparation, + ... IntegerEncodingPreparation, IntegerSlackPreparation, + ... ObjectivePreparation, PreparationPolicy, Sense, + ... SpecialConstraintKind, SpecialConstraintPreparation, + ... ) + >>> expected_special = SpecialConstraintPreparation.lower_special_constraints( + ... kinds={ + ... SpecialConstraintKind.Indicator, + ... SpecialConstraintKind.OneHot, + ... SpecialConstraintKind.Sos1, + ... } + ... ) + >>> expected_penalty = FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0) + >>> first = PreparationPolicy.for_qubo(inequality_integer_slack_max_range=17) + >>> second = PreparationPolicy.for_qubo(inequality_integer_slack_max_range=17) + >>> assert first is not second + >>> assert first.special_constraints == expected_special + >>> assert first.objective == ObjectivePreparation(target=Sense.Minimize) + >>> assert first.integer_slack == IntegerSlackPreparation(max_integer_range=17, slack_upper_bound=17) + >>> assert first.integer_encoding == IntegerEncodingPreparation.log_encode_all_used_integers() + >>> assert first.fixed_penalty == expected_penalty + >>> assert first.binary_power_reduction == BinaryPowerPreparation() + >>> first.fixed_penalty = None + >>> first.binary_power_reduction = None + >>> assert second.fixed_penalty == expected_penalty + >>> assert second.binary_power_reduction == BinaryPowerPreparation() + >>> keyed = PreparationPolicy.for_qubo(penalty_weights={3: 2.0}) + >>> assert keyed.fixed_penalty == FixedPenaltyPreparation.penalty_method_with_fixed_weights(weights={3: 2.0}) + + # Errors + + Supplying uniform and keyed penalty weights together raises ``ValueError``. + + >>> try: + ... PreparationPolicy.for_qubo(uniform_penalty_weight=1.0, penalty_weights={3: 2.0}) + ... except ValueError as error: + ... assert "Both uniform_penalty_weight" in str(error) + ... else: + ... raise AssertionError("mutually exclusive penalty options were accepted") + """ + @staticmethod + def for_hubo( + *, + uniform_penalty_weight: typing.Optional[builtins.float] = None, + penalty_weights: typing.Optional[ + typing.Mapping[builtins.int, builtins.float] + ] = None, + inequality_integer_slack_max_range: builtins.int = 31, + ) -> PreparationPolicy: + r""" + Return a fresh policy for preparing an instance for HUBO formatting. + + ``uniform_penalty_weight`` and ``penalty_weights`` override the default + uniform penalty weight of 1.0 and are mutually exclusive. The keyed form + must cover exactly the active regular constraints at the penalty phase. + ``inequality_integer_slack_max_range`` defaults to 31 and configures both + the exact Integer-slack range and the fallback slack upper bound. Unlike + {meth}`for_qubo`, this policy leaves Binary-power reduction disabled + because HUBO accepts arbitrary polynomial degree. + + # Postconditions + + Each call returns a fresh complete HUBO policy with no Binary-power reduction and exact overrides. + + >>> from ommx import ( + ... FixedPenaltyPreparation, IntegerEncodingPreparation, + ... IntegerSlackPreparation, ObjectivePreparation, + ... PreparationPolicy, Sense, SpecialConstraintKind, + ... SpecialConstraintPreparation, + ... ) + >>> expected_special = SpecialConstraintPreparation.lower_special_constraints( + ... kinds={ + ... SpecialConstraintKind.Indicator, + ... SpecialConstraintKind.OneHot, + ... SpecialConstraintKind.Sos1, + ... } + ... ) + >>> first = PreparationPolicy.for_hubo(inequality_integer_slack_max_range=17) + >>> second = PreparationPolicy.for_hubo(inequality_integer_slack_max_range=17) + >>> assert first is not second + >>> assert first.special_constraints == expected_special + >>> assert first.objective == ObjectivePreparation(target=Sense.Minimize) + >>> assert first.integer_slack == IntegerSlackPreparation(max_integer_range=17, slack_upper_bound=17) + >>> assert first.integer_encoding == IntegerEncodingPreparation.log_encode_all_used_integers() + >>> assert first.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0) + >>> assert first.binary_power_reduction is None + >>> first.fixed_penalty = None + >>> assert second.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0) + >>> uniform = PreparationPolicy.for_hubo(uniform_penalty_weight=4.0) + >>> assert uniform.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=4.0) + + # Errors + + Supplying uniform and keyed penalty weights together raises ``ValueError``. + + >>> try: + ... PreparationPolicy.for_hubo(uniform_penalty_weight=1.0, penalty_weights={3: 2.0}) + ... except ValueError as error: + ... assert "Both uniform_penalty_weight" in str(error) + ... else: + ... raise AssertionError("mutually exclusive penalty options were accepted") + """ class PreparationTargetNotReachedError(builtins.RuntimeError): r""" @@ -6266,17 +6591,13 @@ class Quadratic: Create via DecisionVariable multiplication: - ```python >>> x = DecisionVariable.integer(1) >>> y = DecisionVariable.integer(2) >>> q = x * y + 2*x + 3*y + 1 - ``` Note that `==`, `<=`, `>=` create Constraint objects: - ```python >>> constraint = q <= 10 # Returns Constraint - ``` """ @property def linear_terms(self) -> builtins.dict[builtins.int, builtins.float]: ... @@ -6794,30 +7115,26 @@ class SampleSet: subject to x_1 + x_2 + x_3 = 1 x_1, x_2, x_3 in {0, 1} - ```python + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i) for i in range(3)] >>> instance = Instance.from_components( ... decision_variables=x, ... objective=x[0] + 2*x[1] + 3*x[2], - ... constraints=[sum(x) == 1], - ... sense=Instance.MAXIMIZE, + ... constraints={0: sum(x) == 1}, + ... sense=Sense.Maximize, ... ) - ``` with three samples: - ```python >>> samples = { ... 0: {0: 1, 1: 0, 2: 0}, # x1 = 1, x2 = x3 = 0 ... 1: {0: 0, 1: 0, 2: 1}, # x3 = 1, x1 = x2 = 0 ... 2: {0: 1, 1: 1, 2: 0}, # x1 = x2 = 1, x3 = 0 (infeasible) ... } # ^ sample ID - ``` Note that this will be done by sampling-based solvers, but we do it manually here. We can evaluate the samples via `Instance.evaluate_samples`: - ```python >>> sample_set = instance.evaluate_samples(samples) >>> sample_set.summary # doctest: +NORMALIZE_WHITESPACE objective feasible @@ -6825,25 +7142,20 @@ class SampleSet: 1 3.0 True 0 1.0 True 2 3.0 False - ``` The `summary` attribute shows the objective value, feasibility of each sample. Note that this `feasible` column represents the feasibility of the original constraints, not the relaxed constraints. You can get each sample by `get` as a `Solution` format: - ```python >>> solution = sample_set.get(sample_id=0) >>> solution.objective 1.0 - ``` `best_feasible` returns the best feasible sample, i.e. the largest objective value among feasible samples: - ```python >>> solution = sample_set.best_feasible >>> solution.objective 3.0 - ``` Of course, the sample of smallest objective value is returned for minimization problems. """ @@ -6966,20 +7278,18 @@ class SampleSet: # Examples - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] >>> y = [DecisionVariable.binary(i+3, name="y", subscripts=[i]) for i in range(2)] >>> instance = Instance.from_components( ... decision_variables=x + y, ... objective=sum(x) + sum(y), - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... constraints={}, + ... sense=Sense.Maximize, ... ) >>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}}) >>> sorted(sample_set.decision_variable_names) ['x', 'y'] - ``` """ @property def named_function_names(self) -> builtins.set[builtins.str]: @@ -7068,15 +7378,14 @@ class SampleSet: # Examples - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] >>> y = [DecisionVariable.binary(i+3, name="y", subscripts=[i]) for i in range(2)] >>> instance = Instance.from_components( ... decision_variables=x + y, ... objective=sum(x) + sum(y), - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... constraints={}, + ... sense=Sense.Maximize, ... ) >>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}}) >>> all_vars = sample_set.extract_all_decision_variables(0) @@ -7084,7 +7393,6 @@ class SampleSet: {(0,): 1.0, (1,): 1.0, (2,): 1.0} >>> all_vars["y"] {(0,): 1.0, (1,): 1.0} - ``` """ def extract_constraints(self, name: builtins.str, sample_id: builtins.int) -> dict: r""" @@ -7524,12 +7832,6 @@ class SealedRun: """ def __repr__(self) -> builtins.str: ... -@typing.final -class SensePreparation: - def __eq__(self, other: builtins.object, /) -> builtins.bool: ... - @staticmethod - def as_minimization_problem() -> SensePreparation: ... - @typing.final class Solution: r""" @@ -7677,20 +7979,18 @@ class Solution: # Examples - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] >>> y = [DecisionVariable.binary(i+3, name="y", subscripts=[i]) for i in range(2)] >>> instance = Instance.from_components( ... decision_variables=x + y, ... objective=sum(x) + sum(y), - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... constraints={}, + ... sense=Sense.Maximize, ... ) >>> solution = instance.evaluate({i: 1 for i in range(5)}) >>> sorted(solution.decision_variable_names) ['x', 'y'] - ``` """ @property def named_function_ids(self) -> builtins.set[builtins.int]: ... @@ -7739,19 +8039,17 @@ class Solution: # Examples - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] >>> instance = Instance.from_components( ... decision_variables=x, ... objective=sum(x), - ... constraints=[sum(x) == 1], - ... sense=Instance.MAXIMIZE, + ... constraints={0: sum(x) == 1}, + ... sense=Sense.Maximize, ... ) >>> solution = instance.evaluate({i: 1 for i in range(3)}) >>> solution.extract_decision_variables("x") {(0,): 1.0, (1,): 1.0, (2,): 1.0} - ``` """ def extract_all_decision_variables(self) -> dict: r""" @@ -7766,15 +8064,14 @@ class Solution: # Examples - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] >>> y = [DecisionVariable.binary(i+3, name="y", subscripts=[i]) for i in range(2)] >>> instance = Instance.from_components( ... decision_variables=x + y, ... objective=sum(x) + sum(y), - ... constraints=[], - ... sense=Instance.MAXIMIZE, + ... constraints={}, + ... sense=Sense.Maximize, ... ) >>> solution = instance.evaluate({i: 1 for i in range(5)}) >>> all_vars = solution.extract_all_decision_variables() @@ -7782,7 +8079,6 @@ class Solution: {(0,): 1.0, (1,): 1.0, (2,): 1.0} >>> all_vars["y"] {(0,): 1.0, (1,): 1.0} - ``` """ def extract_constraints(self, name: builtins.str) -> dict: r""" @@ -7794,21 +8090,19 @@ class Solution: # Examples - ```python - >>> from ommx import Instance, DecisionVariable + >>> from ommx import DecisionVariable, Instance, Sense >>> x = [DecisionVariable.binary(i) for i in range(3)] >>> c0 = (x[0] + x[1] == 1).set_name("c").add_subscripts([0]) >>> c1 = (x[1] + x[2] == 1).set_name("c").add_subscripts([1]) >>> instance = Instance.from_components( ... decision_variables=x, ... objective=sum(x), - ... constraints=[c0, c1], - ... sense=Instance.MAXIMIZE, + ... constraints={0: c0, 1: c1}, + ... sense=Sense.Maximize, ... ) >>> solution = instance.evaluate({0: 1, 1: 0, 2: 1}) >>> solution.extract_constraints("c") {(0,): 0.0, (1,): 0.0} - ``` """ def extract_named_functions(self, name: builtins.str) -> dict: r""" @@ -8338,13 +8632,10 @@ def gc( An invalid duration raises {class}`ValueError`; registry and storage failures raise {class}`RuntimeError`. - ```python >>> from ommx.artifact import gc >>> report = gc() >>> report.delete_applied False - - ``` """ def get_default_atol() -> builtins.float: ... @@ -8441,13 +8732,10 @@ def prune_anonymous( An invalid duration raises {class}`ValueError`; registry and storage failures raise {class}`RuntimeError`. - ```python >>> from ommx.artifact import prune_anonymous >>> report = prune_anonymous() >>> report.delete_applied False - - ``` """ def qplib_instance_annotations() -> builtins.dict[ diff --git a/python/ommx/src/artifact.rs b/python/ommx/src/artifact.rs index 14b302b6a..97d50c9a4 100644 --- a/python/ommx/src/artifact.rs +++ b/python/ommx/src/artifact.rs @@ -125,12 +125,10 @@ impl PyArtifactRef { /// An artifact is an OCI container image that stores OMMX data /// (instances, solutions, sample sets, etc.) as layers. /// -/// ```python /// >>> artifact = Artifact.load("ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f") /// >>> print(artifact.image_name) /// ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f /// -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[pyo3(module = "ommx._ommx_rust", name = "Artifact")] @@ -180,12 +178,10 @@ impl PyArtifact { /// archives importable while still making the imported artifact /// addressable in SQLite. /// - /// ```python /// >>> artifact = Artifact.import_archive("data/random_lp_instance.ommx") /// >>> print(artifact.image_name) /// ghcr.io/jij-inc/ommx/random_lp_instance:... /// - /// ``` #[staticmethod] pub fn import_archive(py: Python<'_>, path: PathBuf) -> OmmxPyResult { let _guard = crate::TRACING.attach_parent_context(py); @@ -237,13 +233,11 @@ impl PyArtifact { /// `Artifact.load(image_name)` later), use /// {meth}`Artifact.import_archive`. /// - /// ```python /// >>> manifest = Artifact.inspect_archive("data/random_lp_instance.ommx") /// >>> for layer in manifest.layers: /// ... print(layer.media_type) /// application/org.ommx.v1.instance /// - /// ``` #[staticmethod] pub fn inspect_archive(py: Python<'_>, path: PathBuf) -> OmmxPyResult { let _guard = crate::TRACING.attach_parent_context(py); @@ -255,12 +249,10 @@ impl PyArtifact { /// /// If the image is not found in local registry, it will try to pull from remote registry. /// - /// ```python /// >>> artifact = Artifact.load("ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f") /// >>> print(artifact.image_name) /// ghcr.io/jij-inc/ommx/random_lp_instance:4303c7f /// - /// ``` /// /// Raises {class}`~ommx.artifact.RemoteArtifactNotFoundError` when the /// exact remote reference does not exist. Other remote access failures @@ -1396,13 +1388,11 @@ impl DraftInner { /// Mutable draft for OMMX Artifacts. /// -/// ```python /// >>> draft = ArtifactDraft.temp() /// >>> artifact = draft.commit() /// >>> print(artifact.image_name) /// ttl.sh/...-...-...-...-...:1h /// -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[pyo3(module = "ommx._ommx_rust", name = "ArtifactDraft")] @@ -1417,7 +1407,6 @@ impl PyArtifactDraft { /// returned handle if you also want a `.ommx` archive file for /// sharing. /// - /// ```python /// >>> from ommx.testing import SingleFeasibleLPGenerator, DataType /// >>> generator = SingleFeasibleLPGenerator(3, DataType.INT) /// >>> instance = generator.get_v1_instance() @@ -1429,7 +1418,6 @@ impl PyArtifactDraft { /// >>> print(artifact.image_name) /// ghcr.io/jij-inc/ommx/single_feasible_lp:... /// - /// ``` /// /// Raises {class}`ValueError` when `image_name` is not a valid OCI image /// reference. Registry and storage failures raise {class}`RuntimeError`. @@ -1471,7 +1459,6 @@ impl PyArtifactDraft { /// Call {meth}`Artifact.save(path)` on the returned handle to also /// write a `.ommx` archive file for sharing. /// - /// ```python /// >>> from ommx.testing import SingleFeasibleLPGenerator, DataType /// >>> generator = SingleFeasibleLPGenerator(3, DataType.INT) /// >>> instance = generator.get_v1_instance() @@ -1480,7 +1467,6 @@ impl PyArtifactDraft { /// >>> artifact = draft.commit() /// >>> assert ".ommx.local/anonymous:" in artifact.image_name /// - /// ``` #[staticmethod] pub fn new_anonymous() -> OmmxPyResult { let builder = ommx::artifact::ArtifactDraft::new_anonymous()?; @@ -1491,13 +1477,11 @@ impl PyArtifactDraft { /// Insecure; for tests only. `ttl.sh` is a public registry that /// expires images after one hour. /// - /// ```python /// >>> draft = ArtifactDraft.temp() /// >>> artifact = draft.commit() /// >>> print(artifact.image_name) /// ttl.sh/...-...-...-...-...:1h /// - /// ``` #[staticmethod] pub fn temp() -> OmmxPyResult { let builder = ommx::artifact::ArtifactDraft::temp()?; @@ -1517,7 +1501,6 @@ impl PyArtifactDraft { /// Add an {class}`~ommx.Instance` to the artifact with annotations. /// - /// ```python /// >>> from ommx import Instance /// >>> instance = Instance.minimize() /// >>> instance.title = "test instance" @@ -1526,7 +1509,6 @@ impl PyArtifactDraft { /// >>> print(desc.annotations['org.ommx.v1.instance.title']) /// test instance /// - /// ``` pub fn add_instance( &mut self, py: Python<'_>, @@ -1575,7 +1557,6 @@ impl PyArtifactDraft { /// Add a numpy ndarray to the artifact with npy format. /// - /// ```python /// >>> import numpy as np /// >>> array = np.array([1, 2, 3]) /// >>> draft = ArtifactDraft.temp() @@ -1587,7 +1568,6 @@ impl PyArtifactDraft { /// >>> print(layer.annotations) /// {'org.ommx.user.title': 'test_array'} /// - /// ``` #[pyo3(signature = (array, *, annotation_namespace = "org.ommx.user.", **annotations))] pub fn add_ndarray( &mut self, @@ -1608,7 +1588,6 @@ impl PyArtifactDraft { /// Add a pandas DataFrame to the artifact with parquet format. /// - /// ```python /// >>> import pandas as pd /// >>> df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) /// >>> draft = ArtifactDraft.temp() @@ -1618,7 +1597,6 @@ impl PyArtifactDraft { /// >>> print(layer.media_type) /// application/vnd.apache.parquet /// - /// ``` #[pyo3(signature = (df, *, annotation_namespace = "org.ommx.user.", **annotations))] pub fn add_dataframe( &mut self, @@ -1635,7 +1613,6 @@ impl PyArtifactDraft { /// Add a JSON object to the artifact. /// - /// ```python /// >>> obj = {"a": 1, "b": 2} /// >>> draft = ArtifactDraft.temp() /// >>> _desc = draft.add_json(obj, title="test_json") @@ -1644,7 +1621,6 @@ impl PyArtifactDraft { /// >>> print(layer.media_type) /// application/json /// - /// ``` #[pyo3(signature = (obj, *, annotation_namespace = "org.ommx.user.", **annotations))] pub fn add_json( &mut self, @@ -1852,13 +1828,11 @@ pub fn restore_image( /// An invalid duration raises {class}`ValueError`; registry and storage failures /// raise {class}`RuntimeError`. /// -/// ```python /// >>> from ommx.artifact import prune_anonymous /// >>> report = prune_anonymous() /// >>> report.delete_applied /// False /// -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyfunction] #[pyfunction] #[pyo3(signature = (*, root = None, delete = false, experiments = false, older_than = None))] @@ -1900,13 +1874,11 @@ pub fn prune_anonymous( /// An invalid duration raises {class}`ValueError`; registry and storage failures /// raise {class}`RuntimeError`. /// -/// ```python /// >>> from ommx.artifact import gc /// >>> report = gc() /// >>> report.delete_applied /// False /// -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyfunction] #[pyfunction] #[pyo3(signature = (*, root = None, delete = false, grace_period = "24h"))] diff --git a/python/ommx/src/decision_variable.rs b/python/ommx/src/decision_variable.rs index 59f9b7337..6dd118b58 100644 --- a/python/ommx/src/decision_variable.rs +++ b/python/ommx/src/decision_variable.rs @@ -21,19 +21,15 @@ use std::collections::HashMap; /// /// # Examples /// -/// ```python /// >>> x = DecisionVariable.integer(1) /// >>> x == 1 # Returns Constraint, not bool /// Constraint(...) -/// ``` /// /// For object equality comparison, use the ``equals_to()`` method or compare IDs: /// -/// ```python /// >>> y = DecisionVariable.integer(2) /// >>> x.id == y.id /// False -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] diff --git a/python/ommx/src/function.rs b/python/ommx/src/function.rs index 035199eab..2047cbded 100644 --- a/python/ommx/src/function.rs +++ b/python/ommx/src/function.rs @@ -18,26 +18,22 @@ use std::collections::{BTreeMap, BTreeSet}; /// /// Create from various types: /// -/// ```python +/// >>> x = DecisionVariable.binary(0) +/// >>> y = DecisionVariable.binary(1) /// >>> f = Function(1.0) # Constant /// >>> f = Function(Linear(terms={1: 2}, constant=1)) # Linear /// >>> f = Function(x * y) # From Quadratic expression -/// ``` /// /// Access the terms: /// -/// ```python /// >>> f = Function(Linear(terms={1: 2.5}, constant=1.0)) /// >>> f.terms /// {(1,): 2.5, (): 1.0} -/// ``` /// /// Check the degree: /// -/// ```python /// >>> f.degree() /// 1 -/// ``` #[pyclass(skip_from_py_object)] #[derive(Clone)] pub struct Function(pub ommx::Function); @@ -561,13 +557,11 @@ impl Function { /// /// # Examples /// - /// ```python /// >>> from ommx import Function, Linear, Bound /// >>> f = Function(Linear(terms={1: 2}, constant=3)) # 2*x1 + 3 /// >>> b = f.evaluate_bound({1: Bound(0.0, 2.0)}) /// >>> (b.lower, b.upper) /// (3.0, 7.0) - /// ``` pub fn evaluate_bound(&self, bounds: BTreeMap) -> VariableBound { let bounds: ommx::Bounds = bounds .into_iter() diff --git a/python/ommx/src/instance.rs b/python/ommx/src/instance.rs index b83a2249c..330d558f3 100644 --- a/python/ommx/src/instance.rs +++ b/python/ommx/src/instance.rs @@ -14,7 +14,7 @@ use crate::{ }; use ommx::{ConstraintID, Evaluate, NamedFunctionID, VariableID}; use pyo3::{ - exceptions::{PyKeyError, PyRuntimeError, PyValueError}, + exceptions::{PyKeyError, PyValueError}, prelude::*, types::{PyBytes, PyDict}, Bound, PyAny, @@ -23,49 +23,27 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; /// Optimization problem instance. /// -/// This class also contains annotations like {attr}`~ommx.Instance.title`. -/// OMMX-defined annotations are stored in explicit protobuf fields, while -/// user-defined annotations are stored in the protobuf annotation map and -/// mirrored to OMMX Artifact descriptors. +/// # Invariants /// -/// # Examples +/// Output-only variables are excluded from solver input and evaluated after the full state is populated. /// -/// Create an instance for KnapSack Problem -/// -/// ```python -/// >>> from ommx import Instance, DecisionVariable -/// ``` -/// -/// Profit and weight of items -/// -/// ```python -/// >>> p = [10, 13, 18, 31, 7, 15] -/// >>> w = [11, 15, 20, 35, 10, 33] -/// ``` -/// -/// Decision variables -/// -/// ```python -/// >>> x = [DecisionVariable.binary(i) for i in range(6)] -/// ``` -/// -/// Objective and constraint -/// -/// ```python -/// >>> objective = sum(p[i] * x[i] for i in range(6)) -/// >>> constraint = sum(w[i] * x[i] for i in range(6)) <= 47 -/// ``` -/// -/// Compose as an instance -/// -/// ```python +/// >>> from ommx import DecisionVariable, Instance, Sense +/// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( -/// ... decision_variables=x, -/// ... objective=objective, -/// ... constraints=[constraint], -/// ... sense=Instance.MAXIMIZE, +/// ... decision_variables=[x], +/// ... objective=3 * x, +/// ... constraints={}, +/// ... sense=Sense.Maximize, /// ... ) -/// ``` +/// >>> assert instance.convert_active_objective(Sense.Minimize) +/// >>> fixed = instance.partial_evaluate({0: 1}) +/// >>> assert fixed.sense == Sense.Minimize +/// >>> assert fixed.objective.evaluate({}) == -3.0 +/// >>> assert fixed.required_ids() == set() +/// >>> assert fixed.used_decision_variables == [] +/// >>> assert fixed.populate_state({}).entries == {0: 1.0} +/// >>> solution = fixed.evaluate({}) +/// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] @@ -254,12 +232,10 @@ impl Instance { /// /// # Examples /// - /// ```python /// >>> from ommx import Instance /// >>> instance = Instance.minimize() /// >>> instance.sense == Instance.MINIMIZE /// True - /// ``` #[deprecated(note = "Use Instance.minimize() instead.")] #[staticmethod] pub fn empty() -> OmmxPyResult { @@ -303,11 +279,42 @@ impl Instance { Ok(py.get_type::().into_any().unbind()) } + /// Active optimization sense used by the solver-facing formulation. + /// + /// # Postconditions + /// + /// The property reports the active sense even when evaluation uses a distinct output sense. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> assert instance.sense == Sense.Minimize + /// >>> assert instance.evaluate({0: 1}).sense == Sense.Maximize #[getter] pub fn sense(&self) -> Sense { self.inner.sense().into() } + /// Active objective used by the solver-facing formulation. + /// + /// # Postconditions + /// + /// Assignment replaces the active objective and rebases subsequent output evaluation onto it. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> assert instance.objective.evaluate({0: 1}) == -1.0 + /// >>> instance.objective = 2 * x + /// >>> solution = instance.evaluate({0: 1}) + /// >>> assert instance.sense == Sense.Minimize + /// >>> assert (solution.sense, solution.objective) == (Sense.Minimize, 2.0) #[getter] pub fn objective(&self) -> Function { Function(self.inner.objective().clone()) @@ -818,11 +825,46 @@ impl Instance { .collect() } + /// Serialize this instance in the OMMX v1 wire format. + /// + /// # Errors + /// + /// Serialization raises ``RuntimeError`` when distinct output semantics cannot be represented by v1. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> try: + /// ... instance.to_v1_bytes() + /// ... except RuntimeError: + /// ... pass + /// ... else: + /// ... raise AssertionError("v1 serialization accepted distinct output semantics") pub fn to_v1_bytes<'py>(&self, py: Python<'py>) -> OmmxPyResult> { let _guard = crate::TRACING.attach_parent_context(py); Ok(PyBytes::new(py, &self.inner.to_v1_bytes()?)) } + /// Serialize this instance in the OMMX v2 wire format. + /// + /// # Postconditions + /// + /// A v2 round-trip preserves both active and output objective semantics. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> restored = Instance.from_v2_bytes(instance.to_v2_bytes()) + /// >>> assert restored.sense == Sense.Minimize + /// >>> assert restored.objective.evaluate({0: 1}) == -3.0 + /// >>> solution = restored.evaluate({0: 1}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) pub fn to_v2_bytes<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { let _guard = crate::TRACING.attach_parent_context(py); PyBytes::new(py, &self.inner.to_v2_bytes()) @@ -881,22 +923,21 @@ impl Instance { Ok(crate::display::FunctionDisplay::new(formatted)) } - /// Get the set of decision variable IDs used in the objective and remaining constraints. + /// Get the decision variable IDs required by the active formulation. /// - /// # Examples + /// # Postconditions /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = [DecisionVariable.binary(i) for i in range(3)] + /// IDs referenced only by preserved output semantics are not required solver inputs. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=sum(x), - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize /// ... ) - /// >>> instance.required_ids() - /// {0, 1, 2} - /// ``` + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> fixed = instance.partial_evaluate({0: 1}) + /// >>> assert fixed.required_ids() == set() + /// >>> assert fixed.evaluate({}).objective == 1.0 pub fn required_ids(&self) -> BTreeSet { self.inner .required_ids() @@ -905,12 +946,44 @@ impl Instance { .collect() } + /// Return the active objective in QUBO format without preparing the instance. + /// + /// # Postconditions + /// + /// The returned coefficients represent the active objective rather than preserved output semantics. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=3 * x + 5, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> qubo, offset = instance.as_qubo_format() + /// >>> assert (qubo, offset) == ({(0, 0): -3.0}, -5.0) + /// >>> assert instance.objective.evaluate({0: 1}) == -8.0 + /// >>> assert instance.evaluate({0: 1}).objective == 8.0 pub fn as_qubo_format<'py>(&self, py: Python<'py>) -> OmmxPyResult<(Bound<'py, PyDict>, f64)> { let _guard = crate::TRACING.attach_parent_context(py); let (qubo, constant) = self.inner.as_qubo_format()?; Ok((serde_pyobject::to_pyobject(py, &qubo)?.extract()?, constant)) } + /// Return the active objective in HUBO format without preparing the instance. + /// + /// # Postconditions + /// + /// The returned coefficients represent the active objective rather than preserved output semantics. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=3 * x + 5, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> hubo, offset = instance.as_hubo_format() + /// >>> assert (hubo, offset) == ({(0,): -3.0}, -5.0) + /// >>> assert instance.objective.evaluate({0: 1}) == -8.0 + /// >>> assert instance.evaluate({0: 1}).objective == 8.0 pub fn as_hubo_format<'py>(&self, py: Python<'py>) -> OmmxPyResult<(Bound<'py, PyDict>, f64)> { let _guard = crate::TRACING.attach_parent_context(py); let (hubo, constant) = self.inner.as_hubo_format()?; @@ -919,60 +992,43 @@ impl Instance { /// Convert the instance to a QUBO format. /// - /// This is a **Driver API** for QUBO conversion calling single-purpose methods in order: - /// - /// 1. Convert the instance to a minimization problem by {meth}`~ommx.Instance.as_minimization_problem`. - /// 2. Check continuous variables and raise error if exists. - /// 3. Convert inequality constraints - /// - /// * Try {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack` first with given ``inequality_integer_slack_max_range``. - /// * If failed, {meth}`~ommx.Instance.add_integer_slack_to_inequality` - /// - /// 4. Convert to QUBO with (uniform) penalty method - /// - /// * If ``penalty_weights`` is given (in ``dict[constraint_id, weight]`` form), use {meth}`~ommx.Instance.penalty_method` with the given weights. - /// * If ``uniform_penalty_weight`` is given, use {meth}`~ommx.Instance.uniform_penalty_method` with the given weight. - /// * If both are None, defaults to ``uniform_penalty_weight = 1.0``. + /// # Postconditions /// - /// 5. Log-encode integer variables by {meth}`~ommx.Instance.log_encode`. - /// 6. Finally convert to QUBO format by {meth}`~ommx.Instance.as_qubo_format`. + /// The driver is equivalent to QUBO Preparation followed by active-objective formatting and retains the input output semantics. /// - /// Please see the document of each method for details. - /// If you want to customize the conversion, use the methods above manually. - /// - /// # Examples - /// - /// Let's consider a maximization problem with two integer variables $x_0, x_1 \in [0, 2]$ subject to an inequality: - /// - /// $$\max \; x_0 + x_1 \quad \text{s.t.} \quad x_0 + 2 x_1 \leq 3$$ - /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = [DecisionVariable.integer(i, lower=0, upper=2, name="x", subscripts=[i]) for i in range(2)] + /// >>> import copy + /// >>> from ommx import DecisionVariable, Instance, InstanceClass, PreparationPolicy, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=sum(x), - /// ... constraints=[(x[0] + 2*x[1] <= 3).set_id(0)], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize /// ... ) - /// ``` - /// - /// Convert into QUBO format - /// - /// ```python - /// >>> qubo, offset = instance.to_qubo() - /// >>> qubo - /// {(3, 3): -6.0, (3, 4): 2.0, (3, 5): 4.0, (3, 6): 4.0, (3, 7): 2.0, (3, 8): 4.0, (4, 4): -6.0, (4, 5): 4.0, (4, 6): 4.0, (4, 7): 2.0, (4, 8): 4.0, (5, 5): -9.0, (5, 6): 8.0, (5, 7): 4.0, (5, 8): 8.0, (6, 6): -9.0, (6, 7): 4.0, (6, 8): 8.0, (7, 7): -5.0, (7, 8): 4.0, (8, 8): -8.0} - /// >>> offset - /// 9.0 - /// ``` - /// - /// For the maximization problem, the sense is converted to minimization for generating QUBO, and then converted back to maximization. - /// - /// ```python - /// >>> instance.sense == Instance.MAXIMIZE - /// True - /// ``` + /// >>> explicit = copy.copy(instance) + /// >>> policy = PreparationPolicy.for_qubo(uniform_penalty_weight=2.0) + /// >>> _ = explicit.prepare(InstanceClass.qubo(), policy) + /// >>> expected = explicit.as_qubo_format() + /// >>> actual = instance.to_qubo(uniform_penalty_weight=2.0) + /// >>> assert actual == expected + /// >>> assert InstanceClass.qubo().contains(instance) + /// >>> assert instance.sense == Sense.Minimize + /// >>> assert instance.objective.evaluate({0: 0}) == 2.0 + /// >>> solution = instance.evaluate({0: 0}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0) + /// + /// # Errors + /// + /// Mutually exclusive penalty options raise ``ValueError`` before mutating the instance. + /// + /// >>> unchanged = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize + /// ... ) + /// >>> before = unchanged.to_v2_bytes() + /// >>> try: + /// ... unchanged.to_qubo(uniform_penalty_weight=1.0, penalty_weights={7: 2.0}) + /// ... except ValueError: + /// ... pass + /// ... else: + /// ... raise AssertionError("mutually exclusive penalty options were accepted") + /// >>> assert unchanged.to_v2_bytes() == before #[pyo3(signature = (*, uniform_penalty_weight=None, penalty_weights=None, inequality_integer_slack_max_range=31))] pub fn to_qubo<'py>( &mut self, @@ -982,46 +1038,54 @@ impl Instance { inequality_integer_slack_max_range: u64, ) -> OmmxPyResult<(Bound<'py, PyDict>, f64)> { let _guard = crate::TRACING.attach_parent_context(py); - let is_converted = self.as_minimization_problem(); - self.check_no_continuous_variables("QUBO")?; - self.qubo_hubo_pipeline( + let policy = crate::PreparationPolicy::for_qubo( uniform_penalty_weight, - penalty_weights, + penalty_weights.map(|weights| weights.into_iter().collect()), inequality_integer_slack_max_range, )?; - self.log_encode(py, BTreeSet::new(), None)?; - let result = self.as_qubo_format(py)?; - if is_converted { - self.as_maximization_problem(); - } - Ok(result) + self.prepare(py, &crate::InstanceClass::qubo(), &policy)?; + self.as_qubo_format(py) } /// Convert the instance to a HUBO format. /// - /// This is a **Driver API** for HUBO conversion calling single-purpose methods in order: - /// - /// 1. Convert the instance to a minimization problem by {meth}`~ommx.Instance.as_minimization_problem`. - /// 2. Check continuous variables and raise error if exists. - /// 3. Convert inequality constraints - /// - /// * Try {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack` first with given ``inequality_integer_slack_max_range``. - /// * If failed, {meth}`~ommx.Instance.add_integer_slack_to_inequality` - /// - /// 4. Convert to HUBO with (uniform) penalty method + /// # Postconditions /// - /// * If ``penalty_weights`` is given (in ``dict[constraint_id, weight]`` form), use {meth}`~ommx.Instance.penalty_method` with the given weights. - /// * If ``uniform_penalty_weight`` is given, use {meth}`~ommx.Instance.uniform_penalty_method` with the given weight. - /// * If both are None, defaults to ``uniform_penalty_weight = 1.0``. + /// The driver is equivalent to HUBO Preparation followed by active-objective formatting and retains the input output semantics. /// - /// 5. Log-encode integer variables by {meth}`~ommx.Instance.log_encode`. - /// 6. Finally convert to HUBO format by {meth}`~ommx.Instance.as_hubo_format`. - /// - /// Please see the documentation for {meth}`~ommx.Instance.to_qubo` for more information, or the - /// documentation for each individual method for additional details. The - /// difference between this and {meth}`~ommx.Instance.to_qubo` is that this method isn't - /// restricted to quadratic or linear problems. If you want to customize the - /// conversion, use the individual methods above manually. + /// >>> import copy + /// >>> from ommx import DecisionVariable, Instance, InstanceClass, PreparationPolicy, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize + /// ... ) + /// >>> explicit = copy.copy(instance) + /// >>> policy = PreparationPolicy.for_hubo(uniform_penalty_weight=2.0) + /// >>> _ = explicit.prepare(InstanceClass.hubo(), policy) + /// >>> expected = explicit.as_hubo_format() + /// >>> actual = instance.to_hubo(uniform_penalty_weight=2.0) + /// >>> assert actual == expected + /// >>> assert InstanceClass.hubo().contains(instance) + /// >>> assert instance.sense == Sense.Minimize + /// >>> assert instance.objective.evaluate({0: 0}) == 2.0 + /// >>> solution = instance.evaluate({0: 0}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0) + /// + /// # Errors + /// + /// Mutually exclusive penalty options raise ``ValueError`` before mutating the instance. + /// + /// >>> unchanged = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize + /// ... ) + /// >>> before = unchanged.to_v2_bytes() + /// >>> try: + /// ... unchanged.to_hubo(uniform_penalty_weight=1.0, penalty_weights={7: 2.0}) + /// ... except ValueError: + /// ... pass + /// ... else: + /// ... raise AssertionError("mutually exclusive penalty options were accepted") + /// >>> assert unchanged.to_v2_bytes() == before #[pyo3(signature = (*, uniform_penalty_weight=None, penalty_weights=None, inequality_integer_slack_max_range=31))] pub fn to_hubo<'py>( &mut self, @@ -1031,21 +1095,32 @@ impl Instance { inequality_integer_slack_max_range: u64, ) -> OmmxPyResult<(Bound<'py, PyDict>, f64)> { let _guard = crate::TRACING.attach_parent_context(py); - let is_converted = self.as_minimization_problem(); - self.check_no_continuous_variables("HUBO")?; - self.qubo_hubo_pipeline( + let policy = crate::PreparationPolicy::for_hubo( uniform_penalty_weight, - penalty_weights, + penalty_weights.map(|weights| weights.into_iter().collect()), inequality_integer_slack_max_range, )?; - self.log_encode(py, BTreeSet::new(), None)?; - let result = self.as_hubo_format(py)?; - if is_converted { - self.as_maximization_problem(); - } - Ok(result) + self.prepare(py, &crate::InstanceClass::hubo(), &policy)?; + self.as_hubo_format(py) } + /// Convert this instance into a parameter-free parametric instance. + /// + /// # Postconditions + /// + /// Materializing the result without parameters preserves both active and output objective semantics. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> restored = instance.as_parametric_instance().with_parameters({}) + /// >>> assert restored.sense == Sense.Minimize + /// >>> assert restored.objective.evaluate({0: 1}) == -1.0 + /// >>> solution = restored.evaluate({0: 1}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0) pub fn as_parametric_instance(&self) -> ParametricInstance { ParametricInstance { inner: self.inner.clone().into(), @@ -1071,34 +1146,24 @@ impl Instance { /// > This means the penalty is enforced even for $h(x) < 0$ cases, and $h(x) = 0$ is unfairly favored. /// > This feature is intended to use with {meth}`~ommx.Instance.add_integer_slack_to_inequality`. /// - /// # Examples + /// # Postconditions /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable, Constraint - /// >>> x = [DecisionVariable.binary(i) for i in range(3)] + /// Materialization evaluates penalty energy actively while retaining the pre-penalty objective for output and invalidating optimality transport. + /// + /// >>> from ommx import DecisionVariable, Instance, Optimality, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=sum(x), - /// ... constraints=[x[0] + x[1] == 1, x[1] + x[2] == 1], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize /// ... ) - /// >>> instance.objective - /// Function(x0 + x1 + x2) - /// >>> pi = instance.penalty_method() - /// ``` - /// - /// The constraint is put in removed_constraints - /// - /// ```python - /// >>> pi.constraints - /// [] - /// >>> len(pi.removed_constraints) - /// 2 - /// >>> pi.removed_constraints[0] - /// RemovedConstraint(x0 + x1 - 1 == 0, reason=ommx.Instance.penalty_method, parameter_id=3) - /// >>> pi.removed_constraints[1] - /// RemovedConstraint(x1 + x2 - 1 == 0, reason=ommx.Instance.penalty_method, parameter_id=4) - /// ``` + /// >>> parametric = instance.penalty_method() + /// >>> parameters = {parameter.id: 2.0 for parameter in parametric.parameters} + /// >>> prepared = parametric.with_parameters(parameters) + /// >>> assert parametric.constraints == {} + /// >>> assert 7 in parametric.removed_constraints + /// >>> assert prepared.objective.evaluate({0: 0}) == 2.0 + /// >>> solution = prepared.evaluate({0: 0}) + /// >>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False) + /// >>> assert prepared.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified pub fn penalty_method(&self, py: Python<'_>) -> OmmxPyResult { let _guard = crate::TRACING.attach_parent_context(py); let parametric_instance = self.inner.clone().penalty_method()?; @@ -1125,44 +1190,24 @@ impl Instance { /// > This means the penalty is enforced even for $h(x) < 0$ cases, and $h(x) = 0$ is unfairly favored. /// > This feature is intended to use with {meth}`~ommx.Instance.add_integer_slack_to_inequality`. /// - /// # Examples + /// # Postconditions /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = [DecisionVariable.binary(i) for i in range(3)] + /// Materialization evaluates uniform-penalty energy actively while retaining the pre-penalty objective for output and invalidating optimality transport. + /// + /// >>> from ommx import DecisionVariable, Instance, Optimality, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=sum(x), - /// ... constraints=[sum(x) == 3], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize /// ... ) - /// >>> instance.objective - /// Function(x0 + x1 + x2) - /// >>> pi = instance.uniform_penalty_method() - /// ``` - /// - /// The constraint is put in removed_constraints - /// - /// ```python - /// >>> pi.constraints - /// [] - /// >>> len(pi.removed_constraints) - /// 1 - /// >>> pi.removed_constraints[0] - /// RemovedConstraint(x0 + x1 + x2 - 3 == 0, reason=ommx.Instance.uniform_penalty_method) - /// ``` - /// - /// There is only one parameter in the instance - /// - /// ```python - /// >>> len(pi.parameters) - /// 1 - /// >>> p = pi.parameters[0] - /// >>> p.id - /// 3 - /// >>> p.name - /// 'uniform_penalty_weight' - /// ``` + /// >>> parametric = instance.uniform_penalty_method() + /// >>> parameter_id = parametric.parameters[0].id + /// >>> prepared = parametric.with_parameters({parameter_id: 2.0}) + /// >>> assert parametric.constraints == {} + /// >>> assert 7 in parametric.removed_constraints + /// >>> assert prepared.objective.evaluate({0: 0}) == 2.0 + /// >>> solution = prepared.evaluate({0: 0}) + /// >>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False) + /// >>> assert prepared.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified pub fn uniform_penalty_method(&self, py: Python<'_>) -> OmmxPyResult { let _guard = crate::TRACING.attach_parent_context(py); let parametric_instance = self.inner.clone().uniform_penalty_method()?; @@ -1173,48 +1218,33 @@ impl Instance { /// Evaluate the given {class}`~ommx.State` into a {class}`~ommx.Solution`. /// - /// This method evaluates the problem instance using the provided state (a map from decision variable IDs to their values), - /// and returns a {class}`~ommx.Solution` object containing objective value, evaluated constraint values, and feasibility information. - /// - /// # Examples + /// # Postconditions /// - /// Create a simple instance with three binary variables and evaluate a solution: + /// Evaluation populates the full state before applying preserved output objective semantics. /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = [DecisionVariable.binary(i) for i in range(3)] + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=sum(x), - /// ... constraints=[(x[0] + x[1] <= 1).set_id(0)], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize /// ... ) - /// ``` + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> fixed = instance.partial_evaluate({0: 1}) + /// >>> solution = fixed.evaluate({}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) /// - /// Evaluate it with a state x0 = 1, x1 = 0, x2 = 0, and show the objective and constraints: + /// # Errors /// - /// ```python - /// >>> solution = instance.evaluate({0: 1, 1: 0, 2: 0}) - /// >>> solution.objective - /// 1.0 - /// ``` - /// - /// If the value is out of the range, the solution is infeasible: - /// - /// ```python - /// >>> solution = instance.evaluate({0: 1, 1: 0, 2: 2}) - /// >>> solution.feasible - /// False - /// ``` + /// Evaluation raises ``ValueError`` when an active required ID is missing. /// - /// If some of the decision variables are not set, this raises an error: - /// - /// ```python - /// >>> instance.evaluate({0: 1, 1: 0}) - /// ``` - /// Traceback (most recent call last): - /// ... - /// ValueError: state is missing required variable IDs: {VariableID(2)} + /// >>> required = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize + /// ... ) + /// >>> try: + /// ... required.evaluate({}) + /// ... except ValueError as error: + /// ... assert "missing required variable IDs" in str(error) + /// ... else: + /// ... raise AssertionError("evaluation accepted a missing active ID") #[pyo3(signature = (state, *, atol=None))] pub fn evaluate( &self, @@ -1236,6 +1266,20 @@ impl Instance { /// The input state must contain all decision variables that are actually used /// by this instance's objective and active constraints. The returned /// {class}`~ommx.State` contains every decision variable in the instance. + /// + /// # Postconditions + /// + /// The returned state restores fixed variables needed only by preserved output semantics. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> fixed = instance.partial_evaluate({0: 1}) + /// >>> assert fixed.populate_state({}).entries == {0: 1.0} + /// >>> assert fixed.evaluate({}).objective == 3.0 #[pyo3(signature = (state, *, atol=None))] pub fn populate_state( &self, @@ -1270,31 +1314,23 @@ impl Instance { /// **Returns:** /// A new instance with the specified decision variables fixed to their given values. /// - /// # Examples + /// # Postconditions + /// + /// The new instance rewrites only active expressions while retaining fixed values for output evaluation. /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = DecisionVariable.binary(1) - /// >>> y = DecisionVariable.binary(2) + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=[x, y], - /// ... objective=x + y, - /// ... constraints=[x + y <= 1], - /// ... sense=Instance.MINIMIZE + /// ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize /// ... ) - /// >>> new_instance = instance.partial_evaluate({1: 1}) - /// >>> new_instance.objective - /// Function(x2 + 1) - /// ``` - /// - /// Fixed values are owned by the instance and exposed through the - /// attached decision-variable view: - /// - /// ```python - /// >>> x = new_instance.attached_decision_variable(1) - /// >>> x.substituted_value - /// 1.0 - /// ``` + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> fixed = instance.partial_evaluate({0: 1}) + /// >>> assert instance.required_ids() == {0} + /// >>> assert fixed.required_ids() == set() + /// >>> assert fixed.objective.evaluate({}) == -3.0 + /// >>> assert fixed.attached_decision_variable(0).substituted_value == 1.0 + /// >>> solution = fixed.evaluate({}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) #[pyo3(signature = (state, *, atol=None))] pub fn partial_evaluate( &self, @@ -1312,6 +1348,23 @@ impl Instance { Ok(Self { inner: new_inner }) } + /// Evaluate samples into a sample set. + /// + /// # Postconditions + /// + /// Every sample restores fixed variables before applying preserved output objective semantics. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> fixed = instance.partial_evaluate({0: 1}) + /// >>> sample_set = fixed.evaluate_samples({7: {}}) + /// >>> assert sample_set.sense == Sense.Maximize + /// >>> assert sample_set.objectives[7] == 3.0 + /// >>> assert sample_set.get(7).state.entries == {0: 1.0} #[pyo3(signature = (samples, *, atol=None))] pub fn evaluate_samples( &self, @@ -1345,33 +1398,27 @@ impl Instance { /// /// Generate random state only for used variables /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable, Rng + /// >>> from ommx import DecisionVariable, Instance, Rng, Sense /// >>> x = [DecisionVariable.binary(i) for i in range(5)] /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=x[0] + x[1], - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={}, + /// ... sense=Sense.Maximize, /// ... ) /// /// >>> rng = Rng() /// >>> state = instance.random_state(rng) - /// ``` /// /// Only used variables have values /// - /// ```python /// >>> set(state.entries.keys()) /// {0, 1} - /// ``` /// /// Values respect binary bounds /// - /// ```python /// >>> all(state.entries[i] in [0.0, 1.0] for i in state.entries) /// True - /// ``` pub fn random_state(&self, rng: &Rng) -> OmmxPyResult { let strategy = self.inner.arbitrary_state(); let mut rng_guard = rng.lock()?; @@ -1402,21 +1449,19 @@ impl Instance { /// /// Generate samples for a simple instance: /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable, Rng + /// >>> from ommx import DecisionVariable, Instance, Rng, Sense /// >>> x = [DecisionVariable.binary(i) for i in range(3)] /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=sum(x), - /// ... constraints=[(sum(x) <= 2).set_id(0)], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={0: sum(x) <= 2}, + /// ... sense=Sense.Maximize, /// ... ) /// /// >>> rng = Rng() /// >>> samples = instance.random_samples(rng, num_different_samples=2, num_samples=5) /// >>> samples.num_samples() /// 5 - /// ``` #[pyo3(signature = ( rng, *, @@ -1457,34 +1502,23 @@ impl Instance { /// /// Relax constraint, and restore it. /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i) for i in range(3)] /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=sum(x), - /// ... constraints=[(sum(x) == 3).set_id(1)], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={1: sum(x) == 3}, + /// ... sense=Sense.Maximize, /// ... ) - /// >>> instance.constraints - /// [Constraint(x0 + x1 + x2 - 3 == 0)] - /// ``` + /// >>> assert set(instance.constraints) == {1} /// - /// ```python /// >>> instance.relax_constraint(1, "manual relaxation") - /// >>> instance.constraints - /// [] - /// >>> instance.removed_constraints - /// [RemovedConstraint(x0 + x1 + x2 - 3 == 0, reason=manual relaxation)] - /// ``` + /// >>> assert not instance.constraints + /// >>> assert set(instance.removed_constraints) == {1} /// - /// ```python /// >>> instance.restore_constraint(1) - /// >>> instance.constraints - /// [Constraint(x0 + x1 + x2 - 3 == 0)] - /// >>> instance.removed_constraints - /// [] - /// ``` + /// >>> assert set(instance.constraints) == {1} + /// >>> assert not instance.removed_constraints #[pyo3(signature = (constraint_id, reason, **parameters))] pub fn relax_constraint( &mut self, @@ -1541,7 +1575,6 @@ impl Instance { /// /// # Examples /// - /// ```python /// >>> from ommx import Instance, DecisionVariable, OneHotConstraint /// >>> x = [DecisionVariable.binary(i) for i in range(3)] /// >>> instance = Instance.from_components( @@ -1558,7 +1591,6 @@ impl Instance { /// {0: Constraint(x0 + x1 + x2 - 1 == 0)} /// >>> instance.removed_one_hot_constraints /// {1: RemovedOneHotConstraint(OneHotConstraint(exactly one of {x0, x1, x2} = 1), reason=ommx.Instance.convert_one_hot_to_constraint, constraint_id=0)} - /// ``` pub fn convert_one_hot_to_constraint(&mut self, one_hot_id: u64) -> OmmxPyResult { let new_id = self .inner @@ -1573,7 +1605,6 @@ impl Instance { /// /// # Examples /// - /// ```python /// >>> from ommx import Instance, DecisionVariable, OneHotConstraint /// >>> x = [DecisionVariable.binary(i) for i in range(4)] /// >>> instance = Instance.from_components( @@ -1592,7 +1623,6 @@ impl Instance { /// {} /// >>> instance.constraints /// {0: Constraint(x0 + x1 - 1 == 0), 1: Constraint(x2 + x3 - 1 == 0)} - /// ``` pub fn convert_all_one_hots_to_constraints(&mut self) -> OmmxPyResult> { let ids = self.inner.convert_all_one_hots_to_constraints()?; Ok(ids.into_iter().map(|id| id.into_inner()).collect()) @@ -1632,7 +1662,6 @@ impl Instance { /// /// All-binary SOS1 reduces to ``sum(x_i) - 1 <= 0`` without extra variables: /// - /// ```python /// >>> from ommx import Instance, DecisionVariable, Sos1Constraint /// >>> x = [DecisionVariable.binary(i) for i in range(3)] /// >>> instance = Instance.from_components( @@ -1650,7 +1679,6 @@ impl Instance { /// {0: Constraint(x0 + x1 + x2 - 1 <= 0)} /// >>> instance.removed_sos1_constraints /// {1: RemovedSos1Constraint(Sos1Constraint(at most one of {x0, x1, x2} ≠ 0), reason=ommx.Instance.convert_sos1_to_constraints, constraint_ids=0)} - /// ``` pub fn convert_sos1_to_constraints(&mut self, sos1_id: u64) -> OmmxPyResult> { let new_ids = self.inner.convert_sos1_to_constraints(sos1_id.into())?; Ok(new_ids.into_iter().map(|id| id.into_inner()).collect()) @@ -1669,7 +1697,6 @@ impl Instance { /// /// # Examples /// - /// ```python /// >>> from ommx import Instance, DecisionVariable, Sos1Constraint /// >>> x = [DecisionVariable.binary(i) for i in range(4)] /// >>> instance = Instance.from_components( @@ -1688,7 +1715,6 @@ impl Instance { /// {} /// >>> instance.constraints /// {0: Constraint(x0 + x1 - 1 <= 0), 1: Constraint(x2 + x3 - 1 <= 0)} - /// ``` pub fn convert_all_sos1_to_constraints(&mut self) -> OmmxPyResult>> { let result = self.inner.convert_all_sos1_to_constraints()?; Ok(result @@ -1743,7 +1769,6 @@ impl Instance { /// /// Convert an inequality indicator where the upper side is active: /// - /// ```python /// >>> from ommx import ( /// ... Instance, DecisionVariable, IndicatorConstraint, Equality, /// ... ) @@ -1767,7 +1792,6 @@ impl Instance { /// {} /// >>> instance.constraints /// {0: Constraint(x0 + 3*x1 - 5 <= 0)} - /// ``` pub fn convert_indicator_to_constraint(&mut self, indicator_id: u64) -> OmmxPyResult> { let new_ids = self .inner @@ -1818,42 +1842,23 @@ impl Instance { /// unavailable for a requested variable. Allocation and expression-rewrite /// failures retain their original exception types. /// - /// # Examples + /// # Postconditions /// - /// Let's consider a simple integer programming problem with three integer variables x0, x1, and x2. + /// Encoding rewrites the active objective while output evaluation restores the encoded integer value. /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = [ - /// ... DecisionVariable.integer(i, lower=0, upper=3, name="x", subscripts=[i]) - /// ... for i in range(3) - /// ... ] + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.integer(0, lower=0, upper=3) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=sum(x), - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize /// ... ) - /// >>> instance.objective - /// Function(x0 + x1 + x2) - /// ``` - /// - /// To log-encode the integer variables x0 and x2 (except x1), call log_encode: - /// - /// ```python - /// >>> instance.log_encode({0, 2}) - /// ``` - /// - /// Integer variable in range $[0, 3]$ can be represented by two binary variables: - /// - /// $$x_0 = b_{0,0} + 2 b_{0,1}, \quad x_2 = b_{2,0} + 2 b_{2,1}$$ - /// - /// And these are substituted into the objective and constraint functions. - /// - /// ```python - /// >>> instance.objective - /// Function(x1 + x3 + 2*x4 + x5 + 2*x6) - /// ``` + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> instance.log_encode({0}) + /// >>> encoded_ids = instance.required_ids() + /// >>> assert len(encoded_ids) == 2 + /// >>> state = {variable_id: 1 for variable_id in encoded_ids} + /// >>> assert instance.objective.evaluate(state) == -3.0 + /// >>> solution = instance.evaluate(state) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 3.0) #[pyo3(signature = (decision_variable_ids=BTreeSet::new(), *, atol=None))] pub fn log_encode( &mut self, @@ -1907,21 +1912,23 @@ impl Instance { /// - `atol`: Optional absolute tolerance used when normalizing integer /// bounds before encoding. If None, uses the default tolerance. /// - /// # Examples + /// # Postconditions /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// Encoding rewrites the active objective while output evaluation restores the encoded integer value. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = DecisionVariable.integer(0, lower=2, upper=5, name="x") /// >>> instance = Instance.from_components( - /// ... decision_variables=[x], - /// ... objective=x, - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) /// >>> instance.unary_encode({0}) - /// >>> instance.objective - /// Function(x1 + x2 + x3 + 2) - /// ``` + /// >>> encoded_ids = instance.required_ids() + /// >>> assert len(encoded_ids) == 3 + /// >>> state = {variable_id: 1 for variable_id in encoded_ids} + /// >>> assert instance.objective.evaluate(state) == -5.0 + /// >>> solution = instance.evaluate(state) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 5.0) #[pyo3(signature = (decision_variable_ids=BTreeSet::new(), *, max_range=16, atol=None))] pub fn unary_encode( &mut self, @@ -1979,25 +1986,22 @@ impl Instance { /// substituting a variable that is a member of an indicator, one-hot, or /// SOS1 constraint. /// - /// # Examples + /// # Postconditions /// - /// Encode an integer variable x0 in range $[0, 3]$ into two binary - /// variables by hand, instead of using {meth}`~ommx.Instance.log_encode`: + /// Substitution rewrites the active objective while output evaluation restores the substituted variable value. /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = DecisionVariable.integer(0, lower=0, upper=3, name="x") - /// >>> b = [DecisionVariable.binary(i, name="b", subscripts=[i]) for i in (1, 2)] + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> b = DecisionVariable.binary(1) /// >>> instance = Instance.from_components( - /// ... decision_variables=[x, *b], - /// ... objective=x, - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x, b], objective=x, constraints={}, sense=Sense.Maximize /// ... ) - /// >>> instance.substitute({0: b[0] + 2 * b[1]}) - /// >>> instance.objective - /// Function(x1 + 2*x2) - /// ``` + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> instance.substitute({0: b}) + /// >>> assert instance.required_ids() == {1} + /// >>> assert instance.objective.evaluate({1: 1}) == -1.0 + /// >>> solution = instance.evaluate({1: 1}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0) #[pyo3(signature = (assignments))] pub fn substitute( &mut self, @@ -2033,8 +2037,7 @@ impl Instance { /// /// Let's consider a simple inequality constraint x0 + 2*x1 <= 5. /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Equality, Instance, Sense /// >>> x = [ /// ... DecisionVariable.integer(i, lower=0, upper=3, name="x", subscripts=[i]) /// ... for i in range(3) @@ -2042,25 +2045,20 @@ impl Instance { /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=sum(x), - /// ... constraints=[ - /// ... (x[0] + 2*x[1] <= 5).set_id(0) - /// ... ], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={0: x[0] + 2*x[1] <= 5}, + /// ... sense=Sense.Maximize, /// ... ) - /// >>> instance.constraints[0] - /// Constraint(x0 + 2*x1 - 5 <= 0) - /// ``` /// /// Introduce an integer slack variable /// - /// ```python /// >>> instance.convert_inequality_to_equality_with_integer_slack( /// ... constraint_id=0, /// ... max_integer_range=32 /// ... ) - /// >>> instance.constraints[0] - /// Constraint(x0 + 2*x1 + x3 - 5 == 0) - /// ``` + /// >>> assert instance.constraints[0].function.terms == { + /// ... (0,): 1.0, (1,): 2.0, (3,): 1.0, (): -5.0 + /// ... } + /// >>> assert instance.constraints[0].equality == Equality.EqualToZero /// /// Raises {class}`~ommx.ExactIntegerSlackError` when exact conversion is /// unavailable because the coefficients cannot be normalized or the slack @@ -2100,8 +2098,7 @@ impl Instance { /// /// Let's consider a simple inequality constraint x0 + 2*x1 <= 4. /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Equality, Instance, Sense /// >>> x = [ /// ... DecisionVariable.integer(i, lower=0, upper=3, name="x", subscripts=[i]) /// ... for i in range(3) @@ -2109,25 +2106,21 @@ impl Instance { /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=sum(x), - /// ... constraints=[ - /// ... (x[0] + 2*x[1] <= 4).set_id(0) - /// ... ], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={0: x[0] + 2*x[1] <= 4}, + /// ... sense=Sense.Maximize, /// ... ) - /// >>> instance.constraints[0] - /// Constraint(x0 + 2*x1 - 4 <= 0) - /// ``` /// /// Introduce an integer slack variable s in [0, 2] /// - /// ```python /// >>> b = instance.add_integer_slack_to_inequality( /// ... constraint_id=0, /// ... slack_upper_bound=2 /// ... ) - /// >>> b, instance.constraints[0] - /// (2.0, Constraint(x0 + 2*x1 + 2*x3 - 4 <= 0)) - /// ``` + /// >>> assert b == 2.0 + /// >>> assert instance.constraints[0].function.terms == { + /// ... (0,): 1.0, (1,): 2.0, (3,): 2.0, (): -4.0 + /// ... } + /// >>> assert instance.constraints[0].equality == Equality.LessThanOrEqualToZero pub fn add_integer_slack_to_inequality( &mut self, constraint_id: u64, @@ -2223,7 +2216,6 @@ impl Instance { /// /// # Examples /// - /// ```python /// >>> from ommx import Instance /// >>> instance = Instance.minimize() /// >>> stats = instance.stats() @@ -2231,7 +2223,6 @@ impl Instance { /// 0 /// >>> stats["constraints"]["total"] /// 0 - /// ``` pub fn stats<'py>(&self, py: Python<'py>) -> OmmxPyResult> { let stats = self.inner.stats(); Ok(serde_pyobject::to_pyobject(py, &stats)?.extract()?) @@ -2562,92 +2553,125 @@ impl Instance { /// Convert the instance to a minimization problem. /// - /// If the instance is already a minimization problem, this does nothing. + /// If both the active objective and the output objective already use + /// minimization, this does nothing. /// /// **Returns:** - /// ``True`` if the instance is converted, ``False`` if already a minimization problem. + /// ``True`` if either objective is converted, ``False`` if both already + /// use minimization. /// - /// # Examples + /// # Postconditions /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = [DecisionVariable.binary(i) for i in range(3)] + /// Conversion changes both active and output objective semantics and is idempotent at the target sense. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=sum(x), - /// ... constraints=[sum(x) == 1], - /// ... sense=Instance.MAXIMIZE, + /// ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Maximize /// ... ) - /// >>> instance.sense == Instance.MAXIMIZE - /// True - /// >>> instance.objective - /// Function(x0 + x1 + x2) - /// ``` - /// - /// Convert to a minimization problem - /// - /// ```python - /// >>> instance.as_minimization_problem() - /// True - /// >>> instance.sense == Instance.MINIMIZE - /// True - /// >>> instance.objective - /// Function(-x0 - x1 - x2) - /// ``` - /// - /// If the instance is already a minimization problem, this does nothing - /// - /// ```python - /// >>> instance.as_minimization_problem() - /// False - /// ``` + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> assert instance.evaluate({0: 1}).objective == 3.0 + /// >>> assert instance.as_minimization_problem() + /// >>> solution = instance.evaluate({0: 1}) + /// >>> assert instance.objective.evaluate({0: 1}) == -3.0 + /// >>> assert (solution.sense, solution.objective) == (Sense.Minimize, -3.0) + /// >>> assert not instance.as_minimization_problem() pub fn as_minimization_problem(&mut self) -> bool { self.inner.as_minimization_problem() } /// Convert the instance to a maximization problem. /// - /// If the instance is already a maximization problem, this does nothing. + /// If both the active objective and the output objective already use + /// maximization, this does nothing. /// /// **Returns:** - /// ``True`` if the instance is converted, ``False`` if already a maximization problem. + /// ``True`` if either objective is converted, ``False`` if both already + /// use maximization. /// - /// # Examples + /// # Postconditions /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = [DecisionVariable.binary(i) for i in range(3)] + /// Conversion changes both active and output objective semantics and is idempotent at the target sense. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=sum(x), - /// ... constraints=[sum(x) == 1], - /// ... sense=Instance.MINIMIZE, + /// ... decision_variables=[x], objective=3 * x, constraints={}, sense=Sense.Minimize /// ... ) - /// >>> instance.sense == Instance.MINIMIZE - /// True - /// >>> instance.objective - /// Function(x0 + x1 + x2) - /// ``` + /// >>> assert instance.convert_active_objective(Sense.Maximize) + /// >>> assert instance.evaluate({0: 1}).objective == 3.0 + /// >>> assert instance.as_maximization_problem() + /// >>> solution = instance.evaluate({0: 1}) + /// >>> assert instance.objective.evaluate({0: 1}) == -3.0 + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, -3.0) + /// >>> assert not instance.as_maximization_problem() + pub fn as_maximization_problem(&mut self) -> bool { + self.inner.as_maximization_problem() + } + + /// Convert only the active objective used by a solver-facing formulation. /// - /// Convert to a maximization problem + /// This changes {attr}`~ommx.Instance.sense` and + /// {attr}`~ommx.Instance.objective` to ``target`` while preserving the + /// objective semantics returned by {meth}`~ommx.Instance.evaluate` and + /// {meth}`~ommx.Instance.evaluate_samples`. Use + /// {meth}`~ommx.Instance.as_minimization_problem` or + /// {meth}`~ommx.Instance.as_maximization_problem` when the output objective + /// should be converted as part of the mathematical problem itself. /// - /// ```python - /// >>> instance.as_maximization_problem() - /// True - /// >>> instance.sense == Instance.MAXIMIZE - /// True - /// >>> instance.objective - /// Function(-x0 - x1 - x2) - /// ``` + /// **Returns:** + /// ``True`` if the active objective is converted, ``False`` if it already + /// has ``target``. /// - /// If the instance is already a maximization problem, this does nothing + /// # Postconditions /// - /// ```python - /// >>> instance.as_maximization_problem() - /// False - /// ``` - pub fn as_maximization_problem(&mut self) -> bool { - self.inner.as_maximization_problem() + /// Conversion negates only the active objective and preserves evaluation semantics in either direction. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> for source, target in ((Sense.Maximize, Sense.Minimize), (Sense.Minimize, Sense.Maximize)): + /// ... instance = Instance.from_components( + /// ... decision_variables=[x], objective=3 * x, constraints={}, sense=source + /// ... ) + /// ... before = instance.evaluate({0: 1}) + /// ... assert instance.convert_active_objective(target) + /// ... after = instance.evaluate({0: 1}) + /// ... assert instance.sense == target + /// ... assert instance.objective.evaluate({0: 1}) == -3.0 + /// ... assert (after.sense, after.objective) == (before.sense, before.objective) + /// ... assert not instance.convert_active_objective(target) + pub fn convert_active_objective(&mut self, target: Sense) -> bool { + self.inner.convert_active_objective(target.into()) + } + + /// Map an optimality status for the active solver-facing formulation to + /// the objective semantics returned by evaluation. + /// + /// When the instance records that active-formulation optimality does not + /// transport to its output objective, this returns + /// {attr}`~ommx.Optimality.Unspecified`. + /// + /// # Postconditions + /// + /// Optimality is preserved for equivalent objective conversion and discarded after penalty preparation. + /// + /// >>> from ommx import DecisionVariable, Instance, Optimality, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> equivalent = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert equivalent.convert_active_objective(Sense.Minimize) + /// >>> statuses = (Optimality.Unspecified, Optimality.Optimal, Optimality.NotOptimal) + /// >>> for status in statuses: + /// ... assert equivalent.map_active_optimality(status) == status + /// >>> penalized = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize + /// ... ) + /// >>> _ = penalized.to_qubo(uniform_penalty_weight=1.0) + /// >>> for status in statuses: + /// ... assert penalized.map_active_optimality(status) == Optimality.Unspecified + pub fn map_active_optimality(&self, active: crate::Optimality) -> crate::Optimality { + self.inner.map_active_optimality(active.into()).into() } /// Get a specific decision variable by ID @@ -2724,40 +2748,21 @@ impl Instance { /// **Returns:** /// ``True`` if any reduction was performed, ``False`` otherwise. /// - /// # Examples + /// # Postconditions /// - /// Consider an instance with binary variables and quadratic terms: + /// Reduction simplifies only active expressions while preserving output evaluation semantics. /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable - /// >>> x = [DecisionVariable.binary(i) for i in range(2)] + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) /// >>> instance = Instance.from_components( - /// ... decision_variables=x, - /// ... objective=x[0] * x[0] + x[0] * x[1], - /// ... constraints=[], - /// ... sense=Instance.MINIMIZE, + /// ... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Maximize /// ... ) - /// >>> instance.objective - /// Function(x0*x0 + x0*x1) - /// ``` - /// - /// After reducing binary powers, x0^2 becomes x0: - /// - /// ```python - /// >>> changed = instance.reduce_binary_power() - /// >>> changed - /// True - /// >>> instance.objective - /// Function(x0*x1 + x0) - /// ``` - /// - /// Running it again should not change anything: - /// - /// ```python - /// >>> changed = instance.reduce_binary_power() - /// >>> changed - /// False - /// ``` + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> assert instance.reduce_binary_power() + /// >>> assert instance.objective.evaluate({0: 1}) == -1.0 + /// >>> solution = instance.evaluate({0: 1}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0) + /// >>> assert not instance.reduce_binary_power() pub fn reduce_binary_power(&mut self) -> OmmxPyResult { Ok(self.inner.reduce_binary_power()?) } @@ -2803,119 +2808,22 @@ impl Instance { /// /// # Examples /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i) for i in range(3)] /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=x[0] + x[1], - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={}, + /// ... sense=Sense.Maximize, /// ... ) /// >>> profile = instance.logical_memory_profile() /// >>> isinstance(profile, str) /// True - /// ``` pub fn logical_memory_profile(&self) -> String { self.inner.logical_memory_profile().to_string() } } -impl Instance { - fn check_no_continuous_variables(&self, format_name: &str) -> OmmxPyResult<()> { - let continuous_ids: Vec = self - .inner - .decision_variable_usage() - .used_continuous() - .into_keys() - .map(|id| id.into_inner()) - .collect(); - if !continuous_ids.is_empty() { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "Continuous variables are not supported in {} conversion: IDs={:?}", - format_name, continuous_ids - )) - .into()); - } - Ok(()) - } - - /// Shared pipeline for to_qubo/to_hubo: handle inequality constraints, apply penalty method. - #[tracing::instrument(skip_all)] - fn qubo_hubo_pipeline( - &mut self, - uniform_penalty_weight: Option, - penalty_weights: Option>, - inequality_integer_slack_max_range: u64, - ) -> OmmxPyResult<()> { - // Prepare inequality constraints - let ineq_ids: Vec = self - .inner - .constraints() - .iter() - .filter(|(_, c)| c.equality == ommx::Equality::LessThanOrEqualToZero) - .map(|(id, _)| *id) - .collect(); - for ineq_id in ineq_ids { - let id_u64 = ineq_id.into_inner(); - // Try exact integer slack first, fall back to approximate - if self - .convert_inequality_to_equality_with_integer_slack( - id_u64, - inequality_integer_slack_max_range, - ) - .is_err() - { - self.add_integer_slack_to_inequality(id_u64, inequality_integer_slack_max_range)?; - } - } - - // Penalty method - if !self.inner.constraints().is_empty() { - if uniform_penalty_weight.is_some() && penalty_weights.is_some() { - return Err(pyo3::exceptions::PyValueError::new_err( - "Both uniform_penalty_weight and penalty_weights are specified. Please choose one." - ).into()); - } - if let Some(pw) = penalty_weights { - let pi = self.inner.clone().penalty_method()?; - // Map constraint IDs (from parameter subscripts) to penalty weights - let mut weights = HashMap::new(); - for p in pi.parameters().to_v1_parameters() { - let constraint_id = p.subscripts.first().copied().ok_or_else(|| { - PyRuntimeError::new_err(format!( - "Penalty parameter {} has no subscripts", - p.id - )) - })? as u64; - let w = pw.get(&constraint_id).ok_or_else(|| { - PyValueError::new_err(format!( - "No penalty weight provided for constraint ID {}", - constraint_id - )) - })?; - weights.insert(VariableID::from(p.id).into_inner(), *w); - } - let mut v1_params = ommx::v1::Parameters::default(); - v1_params.entries = weights; - self.inner = pi.with_parameters(v1_params)?; - } else { - let weight = uniform_penalty_weight.unwrap_or(1.0); - let pi = self.inner.clone().uniform_penalty_method()?; - let param_id = - pi.parameters().keys().next().ok_or_else(|| { - PyRuntimeError::new_err("No penalty weight parameter found") - })?; - let mut v1_params = ommx::v1::Parameters::default(); - v1_params.entries.insert(param_id.into_inner(), weight); - self.inner = pi.with_parameters(v1_params)?; - } - } - - Ok(()) - } -} - #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] diff --git a/python/ommx/src/instance_class.rs b/python/ommx/src/instance_class.rs index a01d1c8cd..538990789 100644 --- a/python/ommx/src/instance_class.rs +++ b/python/ommx/src/instance_class.rs @@ -167,6 +167,82 @@ impl InstanceClass { )) } + /// Class of minimization QUBO formulations accepted by + /// {meth}`~ommx.Instance.as_qubo_format` after Preparation. + /// + /// # Postconditions + /// + /// The target accepts unconstrained minimization QUBO formulations and rejects models outside that class. + /// + /// >>> from ommx import DecisionVariable, Instance, InstanceClass, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> linear = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize + /// ... ) + /// >>> quadratic = Instance.from_components( + /// ... decision_variables=[x], objective=x * x, constraints={}, sense=Sense.Minimize + /// ... ) + /// >>> cubic = Instance.from_components( + /// ... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Minimize + /// ... ) + /// >>> maximizing = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> continuous = DecisionVariable.continuous(1) + /// >>> non_binary = Instance.from_components( + /// ... decision_variables=[continuous], objective=continuous, constraints={}, sense=Sense.Minimize + /// ... ) + /// >>> constrained = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={0: x == 1}, sense=Sense.Minimize + /// ... ) + /// >>> target = InstanceClass.qubo() + /// >>> assert target.contains(linear) + /// >>> assert target.contains(quadratic) + /// >>> assert not target.contains(cubic) + /// >>> assert not target.contains(maximizing) + /// >>> assert not target.contains(non_binary) + /// >>> assert not target.contains(constrained) + #[staticmethod] + pub fn qubo() -> Self { + Self(ommx::InstanceClass::qubo()) + } + + /// Class of minimization HUBO formulations accepted by + /// {meth}`~ommx.Instance.as_hubo_format` after Preparation. + /// + /// # Postconditions + /// + /// The target accepts unconstrained minimization Binary HUBO formulations and rejects models outside that class. + /// + /// >>> from ommx import DecisionVariable, Instance, InstanceClass, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> linear = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Minimize + /// ... ) + /// >>> cubic = Instance.from_components( + /// ... decision_variables=[x], objective=x * x * x, constraints={}, sense=Sense.Minimize + /// ... ) + /// >>> maximizing = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> continuous = DecisionVariable.continuous(1) + /// >>> non_binary = Instance.from_components( + /// ... decision_variables=[continuous], objective=continuous, constraints={}, sense=Sense.Minimize + /// ... ) + /// >>> constrained = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={0: x == 1}, sense=Sense.Minimize + /// ... ) + /// >>> target = InstanceClass.hubo() + /// >>> assert target.contains(linear) + /// >>> assert target.contains(cubic) + /// >>> assert not target.contains(maximizing) + /// >>> assert not target.contains(non_binary) + /// >>> assert not target.contains(constrained) + #[staticmethod] + pub fn hubo() -> Self { + Self(ommx::InstanceClass::hubo()) + } + #[getter] pub fn clauses(&self) -> Vec { self.0 diff --git a/python/ommx/src/lib.rs b/python/ommx/src/lib.rs index 2faa2d9a5..75737e61d 100644 --- a/python/ommx/src/lib.rs +++ b/python/ommx/src/lib.rs @@ -203,9 +203,10 @@ fn _ommx_rust(py: Python, m: &Bound) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; - m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -308,9 +309,10 @@ pyo3_stub_gen::reexport_module_members!("ommx" from "ommx._ommx_rust"; "InstanceClassMembershipReport", // Preparation "SpecialConstraintPreparation", - "SensePreparation", + "ObjectivePreparation", "IntegerSlackPreparation", "IntegerEncodingPreparation", + "BinaryPowerPreparation", "FixedPenaltyPreparation", "PreparationPolicy", // Constraint and named function diff --git a/python/ommx/src/linear.rs b/python/ommx/src/linear.rs index 177b16c3b..0d108f0ac 100644 --- a/python/ommx/src/linear.rs +++ b/python/ommx/src/linear.rs @@ -15,30 +15,22 @@ use std::collections::BTreeMap; /// /// Create a linear function `f(x₁, x₂) = 2x₁ + 3x₂ + 1`: /// -/// ```python /// >>> f = Linear(terms={1: 2, 2: 3}, constant=1) -/// ``` /// /// Or create via DecisionVariable arithmetic: /// -/// ```python /// >>> x1 = DecisionVariable.integer(1) /// >>> x2 = DecisionVariable.integer(2) /// >>> g = 2*x1 + 3*x2 + 1 -/// ``` /// /// Compare two linear functions with tolerance: /// -/// ```python /// >>> f.almost_equal(g, atol=1e-12) /// True -/// ``` /// /// Note that `==` creates an equality Constraint, not a boolean: /// -/// ```python /// >>> constraint = f == g # Returns Constraint, not bool -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] diff --git a/python/ommx/src/parameter.rs b/python/ommx/src/parameter.rs index a1ced53e1..bd08dbf86 100644 --- a/python/ommx/src/parameter.rs +++ b/python/ommx/src/parameter.rs @@ -12,12 +12,10 @@ use std::collections::HashMap; /// /// # Examples /// -/// ```python /// >>> p = Parameter(1, name="penalty") /// >>> x = DecisionVariable.integer(2) /// >>> x + p # Returns Linear expression /// Linear(...) -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] diff --git a/python/ommx/src/parametric_instance.rs b/python/ommx/src/parametric_instance.rs index 0c09dfbc9..58979df8e 100644 --- a/python/ommx/src/parametric_instance.rs +++ b/python/ommx/src/parametric_instance.rs @@ -44,11 +44,49 @@ impl ParametricInstance { }) } + /// Serialize this parametric instance in the OMMX v1 wire format. + /// + /// # Errors + /// + /// Serialization raises ``RuntimeError`` when distinct output semantics cannot be represented by v1. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert instance.convert_active_objective(Sense.Minimize) + /// >>> parametric = instance.as_parametric_instance() + /// >>> try: + /// ... parametric.to_v1_bytes() + /// ... except RuntimeError: + /// ... pass + /// ... else: + /// ... raise AssertionError("v1 serialization accepted distinct output semantics") pub fn to_v1_bytes<'py>(&self, py: Python<'py>) -> OmmxPyResult> { let _guard = crate::TRACING.attach_parent_context(py); Ok(PyBytes::new(py, &self.inner.to_v1_bytes()?)) } + /// Serialize this parametric instance in the OMMX v2 wire format. + /// + /// # Postconditions + /// + /// A v2 round-trip preserves both active and output objective semantics through materialization. + /// + /// >>> from ommx import DecisionVariable, Instance, ParametricInstance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> source = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize + /// ... ) + /// >>> parametric = source.uniform_penalty_method() + /// >>> parameter_id = parametric.parameters[0].id + /// >>> restored = ParametricInstance.from_v2_bytes(parametric.to_v2_bytes()) + /// >>> materialized = restored.with_parameters({parameter_id: 2.0}) + /// >>> assert materialized.sense == Sense.Minimize + /// >>> assert materialized.objective.evaluate({0: 0}) == 2.0 + /// >>> solution = materialized.evaluate({0: 0}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Minimize, 0.0) pub fn to_v2_bytes<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { let _guard = crate::TRACING.attach_parent_context(py); PyBytes::new(py, &self.inner.to_v2_bytes()) @@ -219,6 +257,22 @@ impl ParametricInstance { /// Substitute parameters to yield an instance. /// /// Parameters can be provided as a dict mapping parameter IDs to their values. + /// + /// # Postconditions + /// + /// Materialization substitutes parameters in active energy while retaining the pre-penalty objective for output evaluation. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> source = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Minimize + /// ... ) + /// >>> parametric = source.uniform_penalty_method() + /// >>> parameter_id = parametric.parameters[0].id + /// >>> materialized = parametric.with_parameters({parameter_id: 2.0}) + /// >>> assert materialized.objective.evaluate({0: 0}) == 2.0 + /// >>> solution = materialized.evaluate({0: 0}) + /// >>> assert (solution.sense, solution.objective, solution.feasible) == (Sense.Minimize, 0.0, False) pub fn with_parameters(&self, parameters: HashMap) -> OmmxPyResult { let mut v1_params = ommx::v1::Parameters::default(); v1_params.entries = parameters; @@ -296,6 +350,25 @@ impl ParametricInstance { /// IDs, when a parameter ID is used as an assignment target, or when /// substituting a variable that is a member of an indicator, one-hot, or /// SOS1 constraint. + /// + /// # Postconditions + /// + /// Substitution rewrites active expressions while materialized output evaluation restores the substituted variable. + /// + /// >>> from ommx import DecisionVariable, Instance, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> b = DecisionVariable.binary(1) + /// >>> source = Instance.from_components( + /// ... decision_variables=[x, b], objective=x, constraints={}, sense=Sense.Maximize + /// ... ) + /// >>> assert source.convert_active_objective(Sense.Minimize) + /// >>> parametric = source.as_parametric_instance() + /// >>> parametric.substitute({0: b}) + /// >>> materialized = parametric.with_parameters({}) + /// >>> assert materialized.required_ids() == {1} + /// >>> assert materialized.objective.evaluate({1: 1}) == -1.0 + /// >>> solution = materialized.evaluate({1: 1}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 1.0) #[pyo3(signature = (assignments))] pub fn substitute( &mut self, diff --git a/python/ommx/src/polynomial.rs b/python/ommx/src/polynomial.rs index cda53a09c..c41c204b4 100644 --- a/python/ommx/src/polynomial.rs +++ b/python/ommx/src/polynomial.rs @@ -15,17 +15,13 @@ use std::collections::BTreeMap; /// /// Create via DecisionVariable operations: /// -/// ```python /// >>> x = DecisionVariable.integer(1) /// >>> y = DecisionVariable.integer(2) /// >>> p = x * x * y + x * y * y + 1 # Cubic polynomial -/// ``` /// /// Note that `==`, `<=`, `>=` create Constraint objects: /// -/// ```python /// >>> constraint = p == 0 # Returns Constraint -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] diff --git a/python/ommx/src/preparation.rs b/python/ommx/src/preparation.rs index 64466e1f6..939d8bec8 100644 --- a/python/ommx/src/preparation.rs +++ b/python/ommx/src/preparation.rs @@ -1,5 +1,6 @@ use crate::error::OmmxPyResult; -use crate::{Instance, InstanceClass, SpecialConstraintKind}; +use crate::{Instance, InstanceClass, Sense, SpecialConstraintKind}; +use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use std::collections::{BTreeMap, HashSet}; @@ -46,29 +47,46 @@ impl From for SpecialConstraintPreparation { #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass(eq, frozen)] #[derive(Debug, Clone, PartialEq, Eq)] -pub struct SensePreparation { - inner: ommx::SensePreparation, +/// Convert the active objective to ``target`` during Preparation. +/// +/// # Invariants +/// +/// The immutable target records the solver-facing sense requested by Preparation. +/// +/// >>> from ommx import ObjectivePreparation, Sense +/// >>> preparation = ObjectivePreparation(target=Sense.Minimize) +/// >>> assert preparation.target == Sense.Minimize +pub struct ObjectivePreparation { + inner: ommx::ObjectivePreparation, } #[pyo3_stub_gen::derive::gen_stub_pymethods] #[pymethods] -impl SensePreparation { - #[staticmethod] - pub fn as_minimization_problem() -> Self { +impl ObjectivePreparation { + #[new] + #[pyo3(signature = (*, target))] + pub fn new(target: Sense) -> Self { Self { - inner: ommx::SensePreparation::AsMinimizationProblem, + inner: ommx::ObjectivePreparation { + target: target.into(), + }, } } + + #[getter] + pub fn target(&self) -> Sense { + self.inner.target.into() + } } -impl From for ommx::SensePreparation { - fn from(value: SensePreparation) -> Self { +impl From for ommx::ObjectivePreparation { + fn from(value: ObjectivePreparation) -> Self { value.inner } } -impl From for SensePreparation { - fn from(inner: ommx::SensePreparation) -> Self { +impl From for ObjectivePreparation { + fn from(inner: ommx::ObjectivePreparation) -> Self { Self { inner } } } @@ -160,6 +178,35 @@ impl From for IntegerEncodingPreparation { } } +/// Reduce powers of active Binary variables during Preparation. +#[pyo3_stub_gen::derive::gen_stub_pyclass] +#[pyclass(eq, frozen)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BinaryPowerPreparation { + inner: ommx::BinaryPowerPreparation, +} + +#[pyo3_stub_gen::derive::gen_stub_pymethods] +#[pymethods] +impl BinaryPowerPreparation { + #[new] + pub fn new() -> Self { + Self::default() + } +} + +impl From for ommx::BinaryPowerPreparation { + fn from(value: BinaryPowerPreparation) -> Self { + value.inner + } +} + +impl From for BinaryPowerPreparation { + fn from(inner: ommx::BinaryPowerPreparation) -> Self { + Self { inner } + } +} + #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass(eq, frozen)] #[derive(Debug, Clone, PartialEq)] @@ -217,31 +264,224 @@ impl From for FixedPenaltyPreparation { #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass(eq)] #[derive(Debug, Clone, PartialEq, Default)] +/// Select optional transformations applied by {meth}`~ommx.Instance.prepare`. +/// +/// # Invariants +/// +/// A default policy selects no Preparation phase. +/// +/// >>> from ommx import PreparationPolicy +/// >>> policy = PreparationPolicy() +/// >>> assert ( +/// ... policy.special_constraints, +/// ... policy.objective, +/// ... policy.integer_slack, +/// ... policy.integer_encoding, +/// ... policy.fixed_penalty, +/// ... policy.binary_power_reduction, +/// ... ) == (None, None, None, None, None, None) pub struct PreparationPolicy { inner: ommx::PreparationPolicy, } +fn configure_qubo_hubo_policy( + mut inner: ommx::PreparationPolicy, + uniform_penalty_weight: Option, + penalty_weights: Option>, + inequality_integer_slack_max_range: u64, +) -> OmmxPyResult { + if uniform_penalty_weight.is_some() && penalty_weights.is_some() { + return Err(PyValueError::new_err( + "Both uniform_penalty_weight and penalty_weights are specified. Please choose one.", + ) + .into()); + } + + inner.integer_slack = Some(ommx::IntegerSlackPreparation { + max_integer_range: inequality_integer_slack_max_range, + atol: ommx::ATol::default(), + slack_upper_bound: Some(inequality_integer_slack_max_range), + }); + if let Some(weights) = penalty_weights { + inner.fixed_penalty = Some( + ommx::FixedPenaltyPreparation::PenaltyMethodWithFixedWeights { + weights: weights + .into_iter() + .map(|(id, weight)| (ommx::ConstraintID::from(id), weight)) + .collect(), + atol: ommx::ATol::default(), + }, + ); + } else if let Some(weight) = uniform_penalty_weight { + inner.fixed_penalty = Some( + ommx::FixedPenaltyPreparation::UniformPenaltyMethodWithFixedWeight { + weight, + atol: ommx::ATol::default(), + }, + ); + } + + Ok(PreparationPolicy { inner }) +} + #[pyo3_stub_gen::derive::gen_stub_pymethods] #[pymethods] impl PreparationPolicy { #[new] - #[pyo3(signature = (*, special_constraints=None, sense=None, integer_slack=None, integer_encoding=None, fixed_penalty=None))] + #[pyo3(signature = (*, special_constraints=None, objective=None, integer_slack=None, integer_encoding=None, fixed_penalty=None, binary_power_reduction=None))] pub fn new( special_constraints: Option, - sense: Option, + objective: Option, integer_slack: Option, integer_encoding: Option, fixed_penalty: Option, + binary_power_reduction: Option, ) -> Self { let mut inner = ommx::PreparationPolicy::default(); inner.special_constraints = special_constraints.map(Into::into); - inner.sense = sense.map(Into::into); + inner.objective = objective.map(Into::into); inner.integer_slack = integer_slack.map(Into::into); inner.integer_encoding = integer_encoding.map(Into::into); inner.fixed_penalty = fixed_penalty.map(Into::into); + inner.binary_power_reduction = binary_power_reduction.map(Into::into); Self { inner } } + /// Return a fresh policy for preparing an instance for QUBO formatting. + /// + /// ``uniform_penalty_weight`` and ``penalty_weights`` override the default + /// uniform penalty weight of 1.0 and are mutually exclusive. The keyed form + /// must cover exactly the active regular constraints at the penalty phase. + /// ``inequality_integer_slack_max_range`` defaults to 31 and configures both + /// the exact Integer-slack range and the fallback slack upper bound. This + /// QUBO policy also reduces powers of Binary variables before checking the + /// quadratic target. + /// + /// # Postconditions + /// + /// Each call returns a fresh complete QUBO policy whose optional weights and slack range are applied exactly. + /// + /// >>> from ommx import ( + /// ... BinaryPowerPreparation, FixedPenaltyPreparation, + /// ... IntegerEncodingPreparation, IntegerSlackPreparation, + /// ... ObjectivePreparation, PreparationPolicy, Sense, + /// ... SpecialConstraintKind, SpecialConstraintPreparation, + /// ... ) + /// >>> expected_special = SpecialConstraintPreparation.lower_special_constraints( + /// ... kinds={ + /// ... SpecialConstraintKind.Indicator, + /// ... SpecialConstraintKind.OneHot, + /// ... SpecialConstraintKind.Sos1, + /// ... } + /// ... ) + /// >>> expected_penalty = FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0) + /// >>> first = PreparationPolicy.for_qubo(inequality_integer_slack_max_range=17) + /// >>> second = PreparationPolicy.for_qubo(inequality_integer_slack_max_range=17) + /// >>> assert first is not second + /// >>> assert first.special_constraints == expected_special + /// >>> assert first.objective == ObjectivePreparation(target=Sense.Minimize) + /// >>> assert first.integer_slack == IntegerSlackPreparation(max_integer_range=17, slack_upper_bound=17) + /// >>> assert first.integer_encoding == IntegerEncodingPreparation.log_encode_all_used_integers() + /// >>> assert first.fixed_penalty == expected_penalty + /// >>> assert first.binary_power_reduction == BinaryPowerPreparation() + /// >>> first.fixed_penalty = None + /// >>> first.binary_power_reduction = None + /// >>> assert second.fixed_penalty == expected_penalty + /// >>> assert second.binary_power_reduction == BinaryPowerPreparation() + /// >>> keyed = PreparationPolicy.for_qubo(penalty_weights={3: 2.0}) + /// >>> assert keyed.fixed_penalty == FixedPenaltyPreparation.penalty_method_with_fixed_weights(weights={3: 2.0}) + /// + /// # Errors + /// + /// Supplying uniform and keyed penalty weights together raises ``ValueError``. + /// + /// >>> try: + /// ... PreparationPolicy.for_qubo(uniform_penalty_weight=1.0, penalty_weights={3: 2.0}) + /// ... except ValueError as error: + /// ... assert "Both uniform_penalty_weight" in str(error) + /// ... else: + /// ... raise AssertionError("mutually exclusive penalty options were accepted") + #[staticmethod] + #[pyo3(signature = (*, uniform_penalty_weight=None, penalty_weights=None, inequality_integer_slack_max_range=31))] + pub fn for_qubo( + uniform_penalty_weight: Option, + penalty_weights: Option>, + inequality_integer_slack_max_range: u64, + ) -> OmmxPyResult { + configure_qubo_hubo_policy( + ommx::PreparationPolicy::for_qubo(), + uniform_penalty_weight, + penalty_weights, + inequality_integer_slack_max_range, + ) + } + + /// Return a fresh policy for preparing an instance for HUBO formatting. + /// + /// ``uniform_penalty_weight`` and ``penalty_weights`` override the default + /// uniform penalty weight of 1.0 and are mutually exclusive. The keyed form + /// must cover exactly the active regular constraints at the penalty phase. + /// ``inequality_integer_slack_max_range`` defaults to 31 and configures both + /// the exact Integer-slack range and the fallback slack upper bound. Unlike + /// {meth}`for_qubo`, this policy leaves Binary-power reduction disabled + /// because HUBO accepts arbitrary polynomial degree. + /// + /// # Postconditions + /// + /// Each call returns a fresh complete HUBO policy with no Binary-power reduction and exact overrides. + /// + /// >>> from ommx import ( + /// ... FixedPenaltyPreparation, IntegerEncodingPreparation, + /// ... IntegerSlackPreparation, ObjectivePreparation, + /// ... PreparationPolicy, Sense, SpecialConstraintKind, + /// ... SpecialConstraintPreparation, + /// ... ) + /// >>> expected_special = SpecialConstraintPreparation.lower_special_constraints( + /// ... kinds={ + /// ... SpecialConstraintKind.Indicator, + /// ... SpecialConstraintKind.OneHot, + /// ... SpecialConstraintKind.Sos1, + /// ... } + /// ... ) + /// >>> first = PreparationPolicy.for_hubo(inequality_integer_slack_max_range=17) + /// >>> second = PreparationPolicy.for_hubo(inequality_integer_slack_max_range=17) + /// >>> assert first is not second + /// >>> assert first.special_constraints == expected_special + /// >>> assert first.objective == ObjectivePreparation(target=Sense.Minimize) + /// >>> assert first.integer_slack == IntegerSlackPreparation(max_integer_range=17, slack_upper_bound=17) + /// >>> assert first.integer_encoding == IntegerEncodingPreparation.log_encode_all_used_integers() + /// >>> assert first.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0) + /// >>> assert first.binary_power_reduction is None + /// >>> first.fixed_penalty = None + /// >>> assert second.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=1.0) + /// >>> uniform = PreparationPolicy.for_hubo(uniform_penalty_weight=4.0) + /// >>> assert uniform.fixed_penalty == FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=4.0) + /// + /// # Errors + /// + /// Supplying uniform and keyed penalty weights together raises ``ValueError``. + /// + /// >>> try: + /// ... PreparationPolicy.for_hubo(uniform_penalty_weight=1.0, penalty_weights={3: 2.0}) + /// ... except ValueError as error: + /// ... assert "Both uniform_penalty_weight" in str(error) + /// ... else: + /// ... raise AssertionError("mutually exclusive penalty options were accepted") + #[staticmethod] + #[pyo3(signature = (*, uniform_penalty_weight=None, penalty_weights=None, inequality_integer_slack_max_range=31))] + pub fn for_hubo( + uniform_penalty_weight: Option, + penalty_weights: Option>, + inequality_integer_slack_max_range: u64, + ) -> OmmxPyResult { + configure_qubo_hubo_policy( + ommx::PreparationPolicy::for_hubo(), + uniform_penalty_weight, + penalty_weights, + inequality_integer_slack_max_range, + ) + } + #[getter] pub fn special_constraints(&self) -> Option { self.inner.special_constraints.clone().map(Into::into) @@ -253,13 +493,13 @@ impl PreparationPolicy { } #[getter] - pub fn sense(&self) -> Option { - self.inner.sense.map(Into::into) + pub fn objective(&self) -> Option { + self.inner.objective.map(Into::into) } #[setter] - pub fn set_sense(&mut self, value: Option) { - self.inner.sense = value.map(Into::into); + pub fn set_objective(&mut self, value: Option) { + self.inner.objective = value.map(Into::into); } #[getter] @@ -291,6 +531,16 @@ impl PreparationPolicy { pub fn set_fixed_penalty(&mut self, value: Option) { self.inner.fixed_penalty = value.map(Into::into); } + + #[getter] + pub fn binary_power_reduction(&self) -> Option { + self.inner.binary_power_reduction.map(Into::into) + } + + #[setter] + pub fn set_binary_power_reduction(&mut self, value: Option) { + self.inner.binary_power_reduction = value.map(Into::into); + } } #[pyo3_stub_gen::derive::gen_stub_pymethods] @@ -304,18 +554,38 @@ impl Instance { /// /// 1. ``special_constraints``: /// {meth}`~ommx.Instance.lower_special_constraints` - /// 2. ``sense``: {meth}`~ommx.Instance.as_minimization_problem` + /// 2. ``objective``: {meth}`~ommx.Instance.convert_active_objective` /// 3. ``integer_slack``: /// {meth}`~ommx.Instance.convert_inequality_to_equality_with_integer_slack`, /// followed by {meth}`~ommx.Instance.add_integer_slack_to_inequality` only /// when exact conversion is unavailable and ``slack_upper_bound`` is set - /// 4. ``integer_encoding``: {meth}`~ommx.Instance.log_encode` - /// 5. ``fixed_penalty`` + /// 4. ``fixed_penalty`` + /// 5. ``integer_encoding``: {meth}`~ommx.Instance.log_encode` + /// 6. ``binary_power_reduction``: + /// {meth}`~ommx.Instance.reduce_binary_power` /// /// Success guarantees membership only, not Adapter applicability. This /// operation is not transactional, so an error may leave the instance changed. /// {class}`~ommx.PreparationTargetNotReachedError` exposes the final membership /// report when the selections do not reach ``input_class``. + /// + /// # Postconditions + /// + /// Successful Preparation mutates the owner into the target class while preserving output evaluation semantics. + /// + /// >>> from ommx import DecisionVariable, Instance, InstanceClass, Optimality, PreparationPolicy, Sense + /// >>> x = DecisionVariable.binary(0) + /// >>> instance = Instance.from_components( + /// ... decision_variables=[x], objective=x, constraints={7: x == 1}, sense=Sense.Maximize + /// ... ) + /// >>> policy = PreparationPolicy.for_qubo(uniform_penalty_weight=2.0) + /// >>> assert instance.prepare(InstanceClass.qubo(), policy) is None + /// >>> assert InstanceClass.qubo().contains(instance) + /// >>> assert instance.sense == Sense.Minimize + /// >>> assert instance.objective.evaluate({0: 0}) == 2.0 + /// >>> solution = instance.evaluate({0: 0}) + /// >>> assert (solution.sense, solution.objective) == (Sense.Maximize, 0.0) + /// >>> assert instance.map_active_optimality(Optimality.Optimal) == Optimality.Unspecified pub fn prepare( &mut self, py: Python<'_>, diff --git a/python/ommx/src/quadratic.rs b/python/ommx/src/quadratic.rs index acb73cb30..5468f5caf 100644 --- a/python/ommx/src/quadratic.rs +++ b/python/ommx/src/quadratic.rs @@ -14,17 +14,13 @@ use std::collections::BTreeMap; /// /// Create via DecisionVariable multiplication: /// -/// ```python /// >>> x = DecisionVariable.integer(1) /// >>> y = DecisionVariable.integer(2) /// >>> q = x * y + 2*x + 3*y + 1 -/// ``` /// /// Note that `==`, `<=`, `>=` create Constraint objects: /// -/// ```python /// >>> constraint = q <= 10 # Returns Constraint -/// ``` #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] diff --git a/python/ommx/src/sample_set.rs b/python/ommx/src/sample_set.rs index 522b4d3ce..c4d6966b9 100644 --- a/python/ommx/src/sample_set.rs +++ b/python/ommx/src/sample_set.rs @@ -28,30 +28,26 @@ use std::collections::{BTreeMap, BTreeSet}; /// subject to x_1 + x_2 + x_3 = 1 /// x_1, x_2, x_3 in {0, 1} /// -/// ```python +/// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i) for i in range(3)] /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=x[0] + 2*x[1] + 3*x[2], -/// ... constraints=[sum(x) == 1], -/// ... sense=Instance.MAXIMIZE, +/// ... constraints={0: sum(x) == 1}, +/// ... sense=Sense.Maximize, /// ... ) -/// ``` /// /// with three samples: /// -/// ```python /// >>> samples = { /// ... 0: {0: 1, 1: 0, 2: 0}, # x1 = 1, x2 = x3 = 0 /// ... 1: {0: 0, 1: 0, 2: 1}, # x3 = 1, x1 = x2 = 0 /// ... 2: {0: 1, 1: 1, 2: 0}, # x1 = x2 = 1, x3 = 0 (infeasible) /// ... } # ^ sample ID -/// ``` /// /// Note that this will be done by sampling-based solvers, but we do it manually here. /// We can evaluate the samples via `Instance.evaluate_samples`: /// -/// ```python /// >>> sample_set = instance.evaluate_samples(samples) /// >>> sample_set.summary # doctest: +NORMALIZE_WHITESPACE /// objective feasible @@ -59,25 +55,20 @@ use std::collections::{BTreeMap, BTreeSet}; /// 1 3.0 True /// 0 1.0 True /// 2 3.0 False -/// ``` /// /// The `summary` attribute shows the objective value, feasibility of each sample. /// Note that this `feasible` column represents the feasibility of the original constraints, not the relaxed constraints. /// You can get each sample by `get` as a `Solution` format: /// -/// ```python /// >>> solution = sample_set.get(sample_id=0) /// >>> solution.objective /// 1.0 -/// ``` /// /// `best_feasible` returns the best feasible sample, i.e. the largest objective value among feasible samples: /// -/// ```python /// >>> solution = sample_set.best_feasible /// >>> solution.objective /// 3.0 -/// ``` /// /// Of course, the sample of smallest objective value is returned for minimization problems. #[pyo3_stub_gen::derive::gen_stub_pyclass] @@ -309,20 +300,18 @@ impl SampleSet { /// /// # Examples /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] /// >>> y = [DecisionVariable.binary(i+3, name="y", subscripts=[i]) for i in range(2)] /// >>> instance = Instance.from_components( /// ... decision_variables=x + y, /// ... objective=sum(x) + sum(y), - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={}, + /// ... sense=Sense.Maximize, /// ... ) /// >>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}}) /// >>> sorted(sample_set.decision_variable_names) /// ['x', 'y'] - /// ``` #[getter] pub fn decision_variable_names(&self) -> BTreeSet { self.inner.decision_variable_names() @@ -366,15 +355,14 @@ impl SampleSet { /// /// # Examples /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] /// >>> y = [DecisionVariable.binary(i+3, name="y", subscripts=[i]) for i in range(2)] /// >>> instance = Instance.from_components( /// ... decision_variables=x + y, /// ... objective=sum(x) + sum(y), - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={}, + /// ... sense=Sense.Maximize, /// ... ) /// >>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}}) /// >>> all_vars = sample_set.extract_all_decision_variables(0) @@ -382,7 +370,6 @@ impl SampleSet { /// {(0,): 1.0, (1,): 1.0, (2,): 1.0} /// >>> all_vars["y"] /// {(0,): 1.0, (1,): 1.0} - /// ``` pub fn extract_all_decision_variables<'py>( &self, py: Python<'py>, diff --git a/python/ommx/src/solution.rs b/python/ommx/src/solution.rs index b2df9505e..0eafd485f 100644 --- a/python/ommx/src/solution.rs +++ b/python/ommx/src/solution.rs @@ -220,20 +220,18 @@ impl Solution { /// /// # Examples /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] /// >>> y = [DecisionVariable.binary(i+3, name="y", subscripts=[i]) for i in range(2)] /// >>> instance = Instance.from_components( /// ... decision_variables=x + y, /// ... objective=sum(x) + sum(y), - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={}, + /// ... sense=Sense.Maximize, /// ... ) /// >>> solution = instance.evaluate({i: 1 for i in range(5)}) /// >>> sorted(solution.decision_variable_names) /// ['x', 'y'] - /// ``` #[getter] pub fn decision_variable_names(&self) -> BTreeSet { self.inner.decision_variable_names() @@ -261,19 +259,17 @@ impl Solution { /// /// # Examples /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=sum(x), - /// ... constraints=[sum(x) == 1], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={0: sum(x) == 1}, + /// ... sense=Sense.Maximize, /// ... ) /// >>> solution = instance.evaluate({i: 1 for i in range(3)}) /// >>> solution.extract_decision_variables("x") /// {(0,): 1.0, (1,): 1.0, (2,): 1.0} - /// ``` pub fn extract_decision_variables<'py>( &self, py: Python<'py>, @@ -299,15 +295,14 @@ impl Solution { /// /// # Examples /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)] /// >>> y = [DecisionVariable.binary(i+3, name="y", subscripts=[i]) for i in range(2)] /// >>> instance = Instance.from_components( /// ... decision_variables=x + y, /// ... objective=sum(x) + sum(y), - /// ... constraints=[], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={}, + /// ... sense=Sense.Maximize, /// ... ) /// >>> solution = instance.evaluate({i: 1 for i in range(5)}) /// >>> all_vars = solution.extract_all_decision_variables() @@ -315,7 +310,6 @@ impl Solution { /// {(0,): 1.0, (1,): 1.0, (2,): 1.0} /// >>> all_vars["y"] /// {(0,): 1.0, (1,): 1.0} - /// ``` pub fn extract_all_decision_variables<'py>( &self, py: Python<'py>, @@ -341,21 +335,19 @@ impl Solution { /// /// # Examples /// - /// ```python - /// >>> from ommx import Instance, DecisionVariable + /// >>> from ommx import DecisionVariable, Instance, Sense /// >>> x = [DecisionVariable.binary(i) for i in range(3)] /// >>> c0 = (x[0] + x[1] == 1).set_name("c").add_subscripts([0]) /// >>> c1 = (x[1] + x[2] == 1).set_name("c").add_subscripts([1]) /// >>> instance = Instance.from_components( /// ... decision_variables=x, /// ... objective=sum(x), - /// ... constraints=[c0, c1], - /// ... sense=Instance.MAXIMIZE, + /// ... constraints={0: c0, 1: c1}, + /// ... sense=Sense.Maximize, /// ... ) /// >>> solution = instance.evaluate({0: 1, 1: 0, 2: 1}) /// >>> solution.extract_constraints("c") /// {(0,): 0.0, (1,): 0.0} - /// ``` pub fn extract_constraints<'py>( &self, py: Python<'py>, diff --git a/rust/ommx/doc/release_note/3.0.md b/rust/ommx/doc/release_note/3.0.md index 4e54b1f2b..d1a0c0952 100644 --- a/rust/ommx/doc/release_note/3.0.md +++ b/rust/ommx/doc/release_note/3.0.md @@ -226,6 +226,14 @@ and SOS1 collections are serialized directly and top-level roots populate `required_features` so older readers can reject unsupported semantic features instead of silently interpreting a weaker model. +V2 now preserves [`OutputObjective`](crate::OutputObjective) on +[`Instance`](crate::Instance) and +[`ParametricInstance`](crate::ParametricInstance) behind +`FEATURE_OUTPUT_OBJECTIVE`. Because v1 cannot represent this distinction, +[`Instance::to_v1_bytes`](crate::Instance::to_v1_bytes) and +[`ParametricInstance::to_v1_bytes`](crate::ParametricInstance::to_v1_bytes) +return `Err` instead of silently dropping it. + The legacy `ommx.v1` path remains available through explicit `to_v1_bytes` / `from_v1_bytes` names. Because v1 has no first-class representation for indicator, one-hot, or SOS1 constraints, @@ -239,7 +247,8 @@ payload layers for backward compatibility. Related PRs: [#982](https://github.com/Jij-Inc/ommx/pull/982), [#983](https://github.com/Jij-Inc/ommx/pull/983), [#984](https://github.com/Jij-Inc/ommx/pull/984), -[#989](https://github.com/Jij-Inc/ommx/pull/989). +[#989](https://github.com/Jij-Inc/ommx/pull/989), +[#1167](https://github.com/Jij-Inc/ommx/pull/1167). ## ⚙️ Modeling labels and constraint context on the enclosing collection @@ -882,23 +891,29 @@ existing removed constraints of every family are preserved. Active special constraints are never penalty-converted by these operations: their presence returns an error without modifying the instance, so callers must lower them first. When no constraint of any family is active, both operations are exact -identities. +identities. When a penalty is applied, these direct owner operations retain an +existing output-objective pair or capture the pre-penalty active pair. The +active objective contains the weighted squares, while evaluation reports the +pre-penalty mathematical objective. The output pair's optimality-transport flag +is set to false because a finite penalty need not preserve objective ordering. [`PreparationPolicy`](crate::PreparationPolicy) and [`Instance::prepare`](crate::Instance::prepare) provide the Rust Preparation core as a thin, in-place interpreter over these existing `Instance` owner operations. The non-exhaustive Policy is a freely composable table of optional [`SpecialConstraintPreparation`](crate::SpecialConstraintPreparation), -[`SensePreparation`](crate::SensePreparation), +[`ObjectivePreparation`](crate::ObjectivePreparation), [`IntegerSlackPreparation`](crate::IntegerSlackPreparation), +[`FixedPenaltyPreparation`](crate::FixedPenaltyPreparation), [`IntegerEncodingPreparation`](crate::IntegerEncodingPreparation), and -[`FixedPenaltyPreparation`](crate::FixedPenaltyPreparation) phases. Construct -it with `PreparationPolicy::default()` and assign public phase fields; every +[`BinaryPowerPreparation`](crate::BinaryPowerPreparation) phases. Construct it +with `PreparationPolicy::default()` and assign public phase fields; every current or future phase is disabled by default. Each phase runs at most once in -the special-constraint, sense, Integer-slack, Integer-encoding, fixed-penalty -order owned by `Instance::prepare`. Phase-specific types keep their own choices -well formed; for example, keyed and uniform fixed penalties cannot both be -selected for the same penalty phase. +the special-constraint, active-objective, Integer-slack, fixed-penalty, +Integer-encoding, Binary-power-reduction order owned by `Instance::prepare`. +Phase-specific types keep their own choices well formed; for example, keyed +and uniform fixed penalties cannot both be selected for the same penalty +phase. Integer slack introduction is one optional phase. `None` disables the phase. [`IntegerSlackPreparation`](crate::IntegerSlackPreparation) is a single public @@ -913,6 +928,16 @@ only that signal selects the inequality-preserving owner operation with variables exactly and is not an approximation. Every other owner error is propagated unchanged, and numeric validation remains owner-defined. +[`InstanceClass::qubo`](crate::InstanceClass::qubo) and +[`InstanceClass::hubo`](crate::InstanceClass::hubo) define the standard Binary, +unconstrained minimization targets with, respectively, quadratic and unbounded +objective degree. [`PreparationPolicy::for_qubo`](crate::PreparationPolicy::for_qubo) +and [`PreparationPolicy::for_hubo`](crate::PreparationPolicy::for_hubo) return +matching editable policies with the established driver defaults. The QUBO +factory also enables [`BinaryPowerPreparation`](crate::BinaryPowerPreparation) +before its quadratic target check; HUBO accepts arbitrary polynomial degree and +leaves that phase disabled. The factory API docs own the complete defaults. + Preparation checks the target [`InstanceClass`](crate::InstanceClass) before and after each selected phase, stops as soon as membership holds, and guarantees only that membership on success. When all configured phases complete @@ -925,6 +950,21 @@ mismatches. Errors from selected owner operations remain unchanged. Preparation does not add a global transaction, so changes committed by earlier owner operations remain when a later operation fails. +Solver Preparation now preserves the objective semantics observed at entry +while allowing the active objective sent to a backend to change. This affects +workflows that convert the active objective or apply penalties. The executable +postconditions are documented by [`Instance::prepare`](crate::Instance::prepare), +[`Instance::convert_active_objective`](crate::Instance::convert_active_objective), +[`Instance::evaluate`](crate::Instance::evaluate), and +[`Instance::evaluate_samples`](crate::Instance::evaluate_samples). +The established whole-problem conversions +[`Instance::as_minimization_problem`](crate::Instance::as_minimization_problem) +and [`Instance::as_maximization_problem`](crate::Instance::as_maximization_problem) +continue to convert both active and output semantics. +Solver integrations can use +[`Instance::map_active_optimality`](crate::Instance::map_active_optimality) +to retain an active status only when it is valid for the output objective. + [`Instance::into_partial_evaluated`](crate::Instance::into_partial_evaluated) is the consuming counterpart to [`Evaluate::partial_evaluate`](crate::Evaluate::partial_evaluate). Use the @@ -943,7 +983,8 @@ Related PRs: [#1010](https://github.com/Jij-Inc/ommx/pull/1010), [#1145](https://github.com/Jij-Inc/ommx/pull/1145), [#1148](https://github.com/Jij-Inc/ommx/pull/1148), [#1149](https://github.com/Jij-Inc/ommx/pull/1149), -[#1154](https://github.com/Jij-Inc/ommx/pull/1154). +[#1154](https://github.com/Jij-Inc/ommx/pull/1154), +[#1167](https://github.com/Jij-Inc/ommx/pull/1167). ## ⚙️ Formatting and property-test domains diff --git a/rust/ommx/src/instance.rs b/rust/ommx/src/instance.rs index 82b75e1e2..faa55dbe8 100644 --- a/rust/ommx/src/instance.rs +++ b/rust/ommx/src/instance.rs @@ -37,8 +37,9 @@ pub use arbitrary::{InstanceParameters, InstanceSpace}; pub use builder::*; pub use parametric_builder::*; pub use preparation::{ - FixedPenaltyPreparation, IntegerEncodingPreparation, IntegerSlackPreparation, - PreparationPolicy, PreparationTargetNotReached, SensePreparation, SpecialConstraintPreparation, + BinaryPowerPreparation, FixedPenaltyPreparation, IntegerEncodingPreparation, + IntegerSlackPreparation, ObjectivePreparation, PreparationPolicy, PreparationTargetNotReached, + SpecialConstraintPreparation, }; pub use stats::*; @@ -114,6 +115,77 @@ pub enum Sense { Maximize, } +/// Objective semantics used when evaluating solver output. +/// +/// # Invariants +/// +/// The sense, function, and optimality-transport flag are installed and +/// observed as one value owned by an [`Instance`] or [`ParametricInstance`]. +/// +/// ``` +/// use ommx::{linear, DecisionVariable, Function, Instance, Sense, VariableID}; +/// use std::collections::BTreeMap; +/// +/// let original = Function::from(linear!(1)); +/// let mut instance = Instance::builder() +/// .sense(Sense::Maximize) +/// .objective(original.clone()) +/// .decision_variables(BTreeMap::from([( +/// VariableID::from(1), +/// DecisionVariable::binary(), +/// )])) +/// .constraints(BTreeMap::new()) +/// .build() +/// .unwrap(); +/// +/// assert!(instance.convert_active_objective(Sense::Minimize)); +/// let output = instance.output_objective().unwrap(); +/// assert_eq!(output.sense(), Sense::Maximize); +/// assert_eq!(output.function(), &original); +/// assert!(output.preserves_optimality()); +/// ``` +#[derive(Debug, Clone, PartialEq, crate::logical_memory::LogicalMemoryProfile)] +pub struct OutputObjective { + sense: Sense, + function: Function, + preserves_optimality: bool, +} + +impl OutputObjective { + fn new(sense: Sense, function: Function, preserves_optimality: bool) -> Self { + Self { + sense, + function, + preserves_optimality, + } + } + + /// Optimization sense used for output objective values. + pub fn sense(&self) -> Sense { + self.sense + } + + /// Function evaluated to produce output objective values. + pub fn function(&self) -> &Function { + &self.function + } + + /// Whether active-formulation optimality transports to this output objective. + /// + /// This compares the active and output objective orderings over candidate + /// states of the active formulation after state reconstruction. It does + /// not assert feasibility or optimality with respect to removed + /// constraints. + /// + /// `false` means that no such proof is available. It does not assert that + /// a reconstructed state is suboptimal. Use + /// [`Instance::map_active_optimality`] when attaching a solver status to + /// an evaluated output. + pub fn preserves_optimality(&self) -> bool { + self.preserves_optimality + } +} + /// Instance, represents a mathematical optimization problem. /// /// # Multi-type constraint architecture @@ -174,10 +246,13 @@ pub enum Sense { /// kind/bound. /// - The keys of [`Self::constraints`] and [`Self::removed_constraints`] are disjoint sets. /// - The keys of [`Self::decision_variable_dependency`] must be in [`Self::decision_variables`], -/// but must NOT be used in the objective function or constraints. +/// but must NOT be used in the active objective or active constraints. /// These are "dependent variables" whose values are computed from other variables. +/// - Every variable ID in [`Self::output_objective`] belongs to +/// [`Self::decision_variables`]. The output objective does not contribute to +/// the solver-used variable set and is evaluated after state population. /// - Decision variables are classified into mutually exclusive roles: -/// - **used**: Variable IDs appearing in the objective function or active constraints +/// - **used**: Variable IDs appearing in the active objective or active constraints /// - **fixed**: Variable IDs present in [`Self::fixed_decision_variable_values`] and not used /// - **dependent**: Keys of `decision_variable_dependency` that are not used or fixed /// - [`DecisionVariableUsage`] is the reverse-usage index for used decision variables only. @@ -197,6 +272,42 @@ pub enum Sense { /// values. The root [`Instance`] owns the host-level invariant that fixed /// IDs are disjoint from solver-used and dependent variables. /// +/// The output objective remains evaluable when one of its variables is removed +/// from the active formulation by partial evaluation: +/// +/// ``` +/// use ommx::{ +/// linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance, +/// Sense, VariableID, +/// }; +/// use std::collections::{BTreeMap, HashMap}; +/// +/// let variable = VariableID::from(1); +/// let mut instance = Instance::builder() +/// .sense(Sense::Maximize) +/// .objective(Function::from(linear!(1))) +/// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) +/// .constraints(BTreeMap::new()) +/// .build() +/// .unwrap(); +/// assert!(instance.convert_active_objective(Sense::Minimize)); +/// +/// instance +/// .partial_evaluate(&State::from(HashMap::from([(1, 1.0)])), ATol::default()) +/// .unwrap(); +/// assert!(instance.required_ids().is_empty()); +/// assert!(instance +/// .output_objective() +/// .unwrap() +/// .function() +/// .required_ids() +/// .contains(&variable)); +/// +/// let solution = instance.evaluate(&State::default(), ATol::default()).unwrap(); +/// assert_eq!(*solution.sense(), Some(Sense::Maximize)); +/// assert_eq!(*solution.objective(), 1.0); +/// ``` +/// /// ## Special-constraint invariants /// /// Active and removed indicator / one-hot / SOS1 constraints are subject to the @@ -243,6 +354,13 @@ pub struct Instance { sense: Sense, #[getset(get = "pub")] objective: Function, + /// Objective semantics presented by Solution and SampleSet evaluation. + /// + /// `None` means that evaluation directly uses the active [`Self::sense`] / + /// [`Self::objective`] pair and active optimality implicitly transports. + /// A present output pair may equal the active pair when its optimality + /// guarantee still needs to be recorded explicitly. + output_objective: Option, /// Created decision-variable rows, modeling labels, and fixed values. decision_variables: DecisionVariableTable, @@ -275,6 +393,162 @@ pub struct Instance { } impl Instance { + /// Return the preserved objective semantics used for solver output. + /// + /// [`None`] identifies an instance whose active objective is also its + /// output objective. [`Some`] returns the complete root-owned output value. + /// See the [`OutputObjective`] invariants for an executable construction. + pub fn output_objective(&self) -> Option<&OutputObjective> { + self.output_objective.as_ref() + } + + /// Map an optimality status proved for the active formulation to the + /// status that is valid for the output objective. + /// + /// # Postconditions + /// + /// Active optimality is retained exactly while its proof transports to the + /// output objective. + /// + /// ``` + /// use ommx::{ + /// linear, v1::Optimality, ATol, Constraint, ConstraintID, + /// DecisionVariable, Function, Instance, Sense, VariableID, + /// }; + /// use std::collections::BTreeMap; + /// + /// let variable = VariableID::from(1); + /// let mut instance = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(Function::from(linear!(1))), + /// )])) + /// .build() + /// .unwrap(); + /// assert_eq!( + /// instance.map_active_optimality(Optimality::Optimal), + /// Optimality::Optimal, + /// ); + /// + /// instance + /// .uniform_penalty_method_with_fixed_weight(1.0, ATol::default()) + /// .unwrap(); + /// assert_eq!( + /// instance.map_active_optimality(Optimality::Optimal), + /// Optimality::Unspecified, + /// ); + /// assert_eq!( + /// instance.map_active_optimality(Optimality::NotOptimal), + /// Optimality::Unspecified, + /// ); + /// ``` + pub fn map_active_optimality(&self, active: crate::v1::Optimality) -> crate::v1::Optimality { + if self + .output_objective + .as_ref() + .is_none_or(|output| output.preserves_optimality) + { + active + } else { + crate::v1::Optimality::Unspecified + } + } + + /// Preserve the current active objective pair before a transformation + /// separates either the output pair or its optimality-transport status + /// from the solver-facing formulation. + fn capture_output_objective(&mut self) { + if self.output_objective.is_none() { + self.output_objective = Some(OutputObjective::new( + self.sense, + self.objective.clone(), + true, + )); + } + } + + /// Remove an output sidecar when its complete semantics are already + /// represented by the active objective pair. + fn canonicalize_output_objective(&mut self) { + let is_redundant = self.output_objective.as_ref().is_some_and(|output| { + output.preserves_optimality + && output.sense == self.sense + && output.function == self.objective + }); + if is_redundant { + self.output_objective = None; + } + } + + /// Run one Preparation operation while retaining the entry output + /// semantics across every return path. + /// + /// Preparation may rewrite only the active formulation, so its output must + /// continue to mean the effective objective observed at entry. Optimality + /// transport is monotone: an unavailable guarantee at entry or after an + /// applied phase remains unavailable. A redundant preserved sidecar is + /// canonicalized away after the operation completes, including on error. + fn preserve_output_objective_during_preparation( + &mut self, + operation: impl FnOnce(&mut Self) -> R, + ) -> R { + let (entry_sense, entry_function, entry_preserves_optimality) = self + .output_objective + .as_ref() + .map(|output| { + ( + output.sense, + output.function.clone(), + output.preserves_optimality, + ) + }) + .unwrap_or_else(|| (self.sense, self.objective.clone(), true)); + + let result = operation(self); + + let preparation_preserves_optimality = self + .output_objective + .as_ref() + .is_none_or(|output| output.preserves_optimality); + self.output_objective = Some(OutputObjective::new( + entry_sense, + entry_function, + entry_preserves_optimality && preparation_preserves_optimality, + )); + self.canonicalize_output_objective(); + result + } + + /// Record that the active formulation no longer provides an optimality + /// proof for the reconstructed output semantics. Once lost, later rewrites + /// cannot infer that guarantee again. + fn invalidate_output_objective_optimality(&mut self) { + self.capture_output_objective(); + self.output_objective + .as_mut() + .expect("capture_output_objective installs the output objective") + .preserves_optimality = false; + } + + /// Objective pair that Solution and SampleSet evaluation must expose. + fn objective_for_output(&self) -> (Sense, &Function) { + self.output_objective + .as_ref() + .map(|output| (output.sense, &output.function)) + .unwrap_or((self.sense, &self.objective)) + } + + /// Reject conversions to roots that cannot represent output semantics. + fn ensure_no_output_objective(&self, operation: &str) -> crate::Result<()> { + if self.output_objective.is_some() { + crate::bail!("{operation} cannot preserve Instance.output_objective"); + } + Ok(()) + } + /// Access the decision-variable definition table. pub fn decision_variable_table(&self) -> &DecisionVariableTable { &self.decision_variables @@ -590,21 +864,28 @@ impl Instance { /// algebraic expressions cannot distinguish decision-variable references /// from parameter references without the enclosing root. /// - [`Self::decision_variables`] and [`Self::parameters`] together contain -/// every ID that may appear in the objective, regular/indicator constraint -/// bodies, named functions, and dependency RHS expressions. +/// every ID that may appear in the objective, output objective, +/// regular/indicator constraint bodies, named functions, and dependency RHS +/// expressions. /// - The IDs of [`Self::decision_variables`] and [`Self::parameters`] are /// disjoint sets. This shared-namespace invariant is host-level state and /// is validated by [`ParametricInstance::builder`] / protobuf parsing, not /// by [`ParameterTable`] alone. /// - The keys of [`Self::constraints`] and [`Self::removed_constraints`] are disjoint sets. /// - The keys of [`Self::decision_variable_dependency`] must be in [`Self::decision_variables`], -/// but must NOT be used in the objective function or constraints. +/// but must NOT be used in the active objective or active constraints. /// The RHS expressions of [`Self::decision_variable_dependency`] may /// reference IDs from [`Self::decision_variables`] or [`Self::parameters`], /// and may not reference undefined IDs. Parameter IDs in RHS expressions are /// evaluated by [`Self::with_parameters`]. +/// - [`Self::output_objective`] has the same atomic sense/function/optimality +/// semantics as [`Instance::output_objective`]. Its function may reference +/// decision-variable or parameter IDs, including fixed, dependent, or +/// otherwise inactive decision variables. Parameter references are +/// specialized by [`Self::with_parameters`] before the pair is installed on +/// the resulting [`Instance`]. /// - Decision variables are classified into mutually exclusive roles: -/// - **used**: Variable IDs appearing in the objective function or active constraints +/// - **used**: Variable IDs appearing in the active objective or active constraints /// - **fixed**: Variable IDs present in [`Self::fixed_decision_variable_values`] and not used /// - **dependent**: Keys of `decision_variable_dependency` that are not used or fixed /// - [`DecisionVariableUsage`] is the reverse-usage index for used decision variables only. @@ -657,13 +938,14 @@ impl Instance { /// /// [`Self::with_parameters`] partially evaluates parameter IDs out of every /// expression that could contain one when materializing a parametric -/// instance into an [`Instance`]: the objective, active and removed regular -/// constraint bodies, active and removed indicator constraint function -/// bodies, named functions, and `decision_variable_dependency` RHS -/// expressions. OneHot/SOS1 collections (active and removed) pass through -/// unchanged because their variable sets are required to be real decision -/// variables at construction time. The resulting [`Instance`] satisfies its -/// own (stricter) invariants — no parameter IDs survive anywhere. +/// instance into an [`Instance`]: the active objective, output objective, +/// active and removed regular constraint bodies, active and removed indicator +/// constraint function bodies, named functions, and +/// `decision_variable_dependency` RHS expressions. OneHot/SOS1 collections +/// (active and removed) pass through unchanged because their variable sets are +/// required to be real decision variables at construction time. The resulting +/// [`Instance`] satisfies its own (stricter) invariants — no parameter IDs +/// survive anywhere. /// #[derive(Debug, Clone, PartialEq, getset::Getters, Default)] pub struct ParametricInstance { @@ -671,6 +953,13 @@ pub struct ParametricInstance { sense: Sense, #[getset(get = "pub")] objective: Function, + /// Objective semantics presented after parameter specialization and + /// evaluation. + /// + /// `None` means the specialized active [`Self::sense`] / + /// [`Self::objective`] pair is also the output pair. A present function may + /// reference both decision-variable and parameter IDs owned by this root. + output_objective: Option, /// Created decision-variable rows, modeling labels, and fixed values. decision_variables: DecisionVariableTable, #[getset(get = "pub")] @@ -704,6 +993,27 @@ pub struct ParametricInstance { } impl ParametricInstance { + /// Return the preserved objective semantics used after specialization. + /// + /// `None` means the active [`Self::sense`] and [`Self::objective`] define + /// the output semantics directly. + pub fn output_objective(&self) -> Option<&OutputObjective> { + self.output_objective.as_ref() + } + + /// Remove an output sidecar when its complete semantics are already + /// represented by the active objective pair. + fn canonicalize_output_objective(&mut self) { + let is_redundant = self.output_objective.as_ref().is_some_and(|output| { + output.preserves_optimality + && output.sense == self.sense + && output.function == self.objective + }); + if is_redundant { + self.output_objective = None; + } + } + /// Access the decision-variable definition table. pub fn decision_variable_table(&self) -> &DecisionVariableTable { &self.decision_variables diff --git a/rust/ommx/src/instance/arbitrary.rs b/rust/ommx/src/instance/arbitrary.rs index b86564c35..604010a11 100644 --- a/rust/ommx/src/instance/arbitrary.rs +++ b/rust/ommx/src/instance/arbitrary.rs @@ -147,8 +147,9 @@ impl InstanceParameters { /// variables are sampled from strategies. The V3-specific structure — /// fixed and dependent variables, removed constraints, indicator, /// one-hot, and SOS1 families, parameters, description, and annotations - /// — is injected deterministically, so each of those dimensions is - /// exercised at a single representative point (smoke coverage) rather + /// plus a preserved output objective — is injected deterministically, so + /// each of those dimensions is exercised at a single representative point + /// (smoke coverage) rather /// than sampled. Every generated instance contains all V3 features; /// feature-absent combinations are covered by the narrower spaces such /// as [`Self::regular_only`]. @@ -560,6 +561,14 @@ impl Arbitrary for Instance { "org.ommx.user.arbitrary".to_string(), "true".to_string(), ); + match instance.sense() { + Sense::Minimize => { + instance.convert_active_objective(Sense::Maximize); + } + Sense::Maximize => { + instance.convert_active_objective(Sense::Minimize); + } + } } instance @@ -583,6 +592,11 @@ mod tests { prop_assert!(instance.decision_variables.contains_key(&id)); } } + if let Some(output) = instance.output_objective() { + for id in output.function().required_ids() { + prop_assert!(instance.decision_variables.contains_key(&id)); + } + } for c in instance.constraints().values() { for ids in c.function().keys() { for id in ids { @@ -655,6 +669,7 @@ mod tests { prop_assert!(instance.parameters.is_some()); prop_assert!(instance.description.is_some()); prop_assert!(!instance.annotations.is_empty()); + prop_assert!(instance.output_objective().is_some()); prop_assert!( instance .indicator_constraints() diff --git a/rust/ommx/src/instance/builder.rs b/rust/ommx/src/instance/builder.rs index a41482d43..4967d8e77 100644 --- a/rust/ommx/src/instance/builder.rs +++ b/rust/ommx/src/instance/builder.rs @@ -452,6 +452,7 @@ impl InstanceBuilder { Ok(Instance { sense, objective, + output_objective: None, decision_variables, constraint_collection: ConstraintCollection::with_context( constraints, diff --git a/rust/ommx/src/instance/convert.rs b/rust/ommx/src/instance/convert.rs index b31ba544a..47c9fbe29 100644 --- a/rust/ommx/src/instance/convert.rs +++ b/rust/ommx/src/instance/convert.rs @@ -5,37 +5,152 @@ use crate::{ }; use std::{collections::BTreeMap, ops::Neg}; +fn convert_objective_pair(sense: &mut Sense, objective: &mut Function, target: Sense) -> bool { + if *sense == target { + false + } else { + *sense = target; + *objective = std::mem::take(objective).neg(); + true + } +} + impl Instance { - /// Convert the instance to a minimization problem. + /// Convert only the active, solver-facing objective to `target`. /// - /// If the instance is already a minimization problem, this does nothing. - /// Otherwise, it negates the objective function and changes the sense to minimize. + /// # Postconditions /// - /// Returns `true` if the instance was converted, `false` if it was already a minimization problem. - pub fn as_minimization_problem(&mut self) -> bool { - if self.sense == Sense::Minimize { + /// Only the active pair changes, while evaluation retains the entry output semantics. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance, + /// Sampled, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let original = Function::from(linear!(1)); + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(original.clone()) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// let state = State::from(HashMap::from([(1, 1.0)])); + /// + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// assert_eq!(instance.sense(), Sense::Minimize); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -1.0); + /// assert!(!instance.convert_active_objective(Sense::Minimize)); + /// + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// let sample_set = instance + /// .evaluate_samples(&Sampled::from(state), ATol::default()) + /// .unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Maximize)); + /// assert_eq!(*solution.objective(), 1.0); + /// assert_eq!(*sample_set.sense(), Sense::Maximize); + /// let sample_id = sample_set.sample_ids().into_iter().next().unwrap(); + /// assert_eq!(sample_set.objectives().get(sample_id), Some(&1.0)); + /// ``` + pub fn convert_active_objective(&mut self, target: Sense) -> bool { + let converted = if self.sense == target { false } else { - self.sense = Sense::Minimize; - self.objective = std::mem::take(&mut self.objective).neg(); - true - } + self.capture_output_objective(); + convert_objective_pair(&mut self.sense, &mut self.objective, target) + }; + self.canonicalize_output_objective(); + converted + } + + /// Convert the complete instance objective semantics to minimization. + /// + /// # Postconditions + /// + /// Both active and output objective semantics become minimization semantics. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance, + /// Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// let state = State::from(HashMap::from([(1, 1.0)])); + /// + /// assert!(instance.as_minimization_problem()); + /// assert_eq!(instance.sense(), Sense::Minimize); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -1.0); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Minimize)); + /// assert_eq!(*solution.objective(), -1.0); + /// assert!(!instance.as_minimization_problem()); + /// ``` + pub fn as_minimization_problem(&mut self) -> bool { + self.convert_problem_objective(Sense::Minimize) } - /// Convert the instance to a maximization problem. + /// Convert the complete instance objective semantics to maximization. + /// + /// # Postconditions + /// + /// Both active and output objective semantics become maximization semantics. /// - /// If the instance is already a maximization problem, this does nothing. - /// Otherwise, it negates the objective function and changes the sense to maximize. + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance, + /// Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; /// - /// Returns `true` if the instance was converted, `false` if it was already a maximization problem. + /// let mut instance = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// let state = State::from(HashMap::from([(1, 1.0)])); + /// + /// assert!(instance.as_maximization_problem()); + /// assert_eq!(instance.sense(), Sense::Maximize); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -1.0); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Maximize)); + /// assert_eq!(*solution.objective(), -1.0); + /// assert!(!instance.as_maximization_problem()); + /// ``` pub fn as_maximization_problem(&mut self) -> bool { - if self.sense == Sense::Maximize { - false + self.convert_problem_objective(Sense::Maximize) + } + + fn convert_problem_objective(&mut self, target: Sense) -> bool { + let active_converted = convert_objective_pair(&mut self.sense, &mut self.objective, target); + let output_converted = if let Some(output) = &mut self.output_objective { + convert_objective_pair(&mut output.sense, &mut output.function, target) } else { - self.sense = Sense::Maximize; - self.objective = std::mem::take(&mut self.objective).neg(); - true - } + false + }; + self.canonicalize_output_objective(); + active_converted || output_converted } } @@ -44,6 +159,7 @@ impl From for ParametricInstance { Instance { sense, objective, + output_objective, decision_variables, constraint_collection, indicator_constraint_collection, @@ -59,6 +175,7 @@ impl From for ParametricInstance { ParametricInstance { sense, objective, + output_objective, decision_variables, parameters: ParameterTable::default(), constraint_collection, @@ -100,6 +217,45 @@ fn materialize_constraint_collection_parameters( } impl ParametricInstance { + /// Materialize every parameter into an [`Instance`]. + /// + /// # Postconditions + /// + /// Materialization removes parameter IDs from both active and output objectives. + /// + /// ``` + /// use ommx::{ + /// linear, v1::{Parameters, State}, ATol, Constraint, ConstraintID, + /// DecisionVariable, Evaluate, Function, Instance, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let variable = VariableID::from(1); + /// let source = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(Function::from(linear!(1))), + /// )])) + /// .build() + /// .unwrap(); + /// let parametric = source.uniform_penalty_method().unwrap(); + /// let penalty = *parametric.parameters().keys().next().unwrap(); + /// let mut parameters = Parameters::default(); + /// parameters.entries.insert(penalty.into_inner(), 2.0); + /// let instance = parametric.with_parameters(parameters).unwrap(); + /// + /// assert!(instance.objective().required_ids().contains(&variable)); + /// assert!(!instance.objective().required_ids().contains(&penalty)); + /// assert_eq!(instance.output_objective().unwrap().sense(), Sense::Minimize); + /// assert!(!instance.output_objective().unwrap().preserves_optimality()); + /// let solution = instance + /// .evaluate(&State::from(HashMap::from([(1, 0.0)])), ATol::default()) + /// .unwrap(); + /// assert_eq!(*solution.objective(), 0.0); + /// ``` pub fn with_parameters(self, parameters: crate::v1::Parameters) -> crate::Result { use std::collections::BTreeSet; @@ -129,9 +285,14 @@ impl ParametricInstance { }; let atol = ATol::default(); - // Partially evaluate the objective, constraints, and named functions + // Partially evaluate the active and output objectives, constraints, + // and named functions. let mut objective = self.objective; objective.partial_evaluate(&state, atol)?; + let mut output_objective = self.output_objective; + if let Some(output_objective) = &mut output_objective { + output_objective.function.partial_evaluate(&state, atol)?; + } // Both active and removed regular constraint bodies need the parameter // substitution applied — otherwise the resulting `Instance` would @@ -160,9 +321,10 @@ impl ParametricInstance { let mut decision_variable_dependency = self.decision_variable_dependency; decision_variable_dependency.partial_evaluate(&state, atol)?; - Ok(Instance { + let mut instance = Instance { sense: self.sense, objective, + output_objective, decision_variables: self.decision_variables, constraint_collection, indicator_constraint_collection, @@ -178,7 +340,140 @@ impl ParametricInstance { parameters: Some(parameters), description: self.description, annotations: self.annotations, - }) + }; + instance.canonicalize_output_objective(); + Ok(instance) + } +} + +#[cfg(test)] +mod output_objective_tests { + use super::*; + use crate::{linear, v1::State, ATol, DecisionVariable, Evaluate, Sampled}; + use std::collections::{BTreeMap, HashMap}; + + fn maximizing_binary_instance() -> Instance { + Instance::builder() + .sense(Sense::Maximize) + .objective(Function::from(linear!(1))) + .decision_variables(BTreeMap::from([( + VariableID::from(1), + DecisionVariable::binary(), + )])) + .constraints(BTreeMap::new()) + .build() + .unwrap() + } + + fn assert_evaluation(instance: &Instance, sense: Sense, objective: f64) { + let state = State::from(HashMap::from([(1, 1.0)])); + let solution = instance.evaluate(&state, ATol::default()).unwrap(); + assert_eq!(*solution.sense(), Some(sense)); + assert_eq!(*solution.objective(), objective); + + let sample_set = instance + .evaluate_samples(&Sampled::from(state), ATol::default()) + .unwrap(); + assert_eq!(*sample_set.sense(), sense); + let sample_id = sample_set.sample_ids().into_iter().next().unwrap(); + assert_eq!(sample_set.objectives().get(sample_id), Some(&objective)); + } + + #[test] + fn conversions_preserve_false_optimality_transport() { + let mut instance = maximizing_binary_instance(); + let original_objective = instance.objective().clone(); + instance.output_objective = Some(OutputObjective::new( + Sense::Maximize, + original_objective.clone(), + false, + )); + + assert!(instance.convert_active_objective(Sense::Minimize)); + let output = instance.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Maximize); + assert_eq!(output.function(), &original_objective); + assert!(!output.preserves_optimality()); + assert_evaluation(&instance, Sense::Maximize, 1.0); + + // Only the output pair still needs normalization. Its false flag must + // keep the sidecar present even when both pairs become identical. + assert!(instance.as_minimization_problem()); + let output = instance.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Minimize); + assert_eq!(output.function(), instance.objective()); + assert!(!output.preserves_optimality()); + assert_evaluation(&instance, Sense::Minimize, -1.0); + + assert!(instance.as_maximization_problem()); + let output = instance.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Maximize); + assert_eq!(output.function(), instance.objective()); + assert!(!output.preserves_optimality()); + assert_evaluation(&instance, Sense::Maximize, 1.0); + } + + #[test] + fn with_parameters_specializes_parameterized_output_objective() { + let output_function = Function::from((linear!(1) + linear!(100)).unwrap()); + let mut parametric = ParametricInstance::new( + Sense::Minimize, + output_function.clone().neg(), + BTreeMap::from([(VariableID::from(1), DecisionVariable::continuous())]), + ParameterTable::from_ids([VariableID::from(100)].into_iter().collect()), + BTreeMap::new(), + ) + .unwrap(); + parametric.output_objective = + Some(OutputObjective::new(Sense::Maximize, output_function, true)); + + let materialized = parametric + .with_parameters(crate::v1::Parameters { + entries: HashMap::from([(100, 2.0)]), + }) + .unwrap(); + + let output = materialized.output_objective().unwrap(); + assert_eq!( + output.function(), + &Function::from((linear!(1) + crate::coeff!(2.0)).unwrap()) + ); + assert_eq!( + output.function().required_ids(), + VariableIDSet::from([VariableID::from(1)]) + ); + assert!(output.preserves_optimality()); + let solution = materialized + .evaluate(&State::from(HashMap::from([(1, 3.0)])), ATol::default()) + .unwrap(); + assert_eq!(*solution.sense(), Some(Sense::Maximize)); + assert_eq!(*solution.objective(), 5.0); + } + + #[test] + fn with_parameters_canonicalizes_redundant_output_objective() { + let mut parametric = ParametricInstance::new( + Sense::Minimize, + Function::from(linear!(1)), + BTreeMap::from([(VariableID::from(1), DecisionVariable::continuous())]), + ParameterTable::from_ids([VariableID::from(100)].into_iter().collect()), + BTreeMap::new(), + ) + .unwrap(); + parametric.output_objective = Some(OutputObjective::new( + Sense::Minimize, + Function::from((linear!(1) + linear!(100)).unwrap()), + true, + )); + + let materialized = parametric + .with_parameters(crate::v1::Parameters { + entries: HashMap::from([(100, 0.0)]), + }) + .unwrap(); + + assert!(materialized.output_objective().is_none()); + assert!(materialized.to_v1_bytes().is_ok()); } } diff --git a/rust/ommx/src/instance/evaluate.rs b/rust/ommx/src/instance/evaluate.rs index a5f84324f..17401328f 100644 --- a/rust/ommx/src/instance/evaluate.rs +++ b/rust/ommx/src/instance/evaluate.rs @@ -597,11 +597,40 @@ impl Evaluate for Instance { type Output = crate::Solution; type SampledOutput = crate::SampleSet; + /// # Postconditions + /// + /// Evaluation reports the preserved output sense and objective. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance, + /// Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// let state = State::from(HashMap::from([(1, 1.0)])); + /// + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Maximize)); + /// assert_eq!(*solution.objective(), 1.0); + /// ``` #[tracing::instrument(skip_all)] fn evaluate(&self, state: &v1::State, atol: ATol) -> Result { let state = self.populate_state(state.clone(), atol)?; - let objective = self.objective.evaluate(&state, atol)?; + let (sense, output_objective) = self.objective_for_output(); + let objective = output_objective.evaluate(&state, atol)?; let evaluated_constraints = self.constraint_collection.evaluate(&state, atol)?; let evaluated_indicator_constraints = self .indicator_constraint_collection @@ -618,8 +647,6 @@ impl Evaluate for Instance { let evaluated_named_functions = self.named_functions.evaluate(&state, atol)?; - let sense = self.sense(); - // SAFETY: Instance invariants guarantee Solution invariants let solution = unsafe { crate::Solution::builder() @@ -639,6 +666,35 @@ impl Evaluate for Instance { Ok(solution) } + /// # Postconditions + /// + /// Sample evaluation reports the preserved output semantics for every sample. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance, + /// Sampled, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// let samples = Sampled::from(State::from(HashMap::from([(1, 1.0)]))); + /// + /// let sample_set = instance.evaluate_samples(&samples, ATol::default()).unwrap(); + /// assert_eq!(*sample_set.sense(), Sense::Maximize); + /// let sample_id = sample_set.sample_ids().into_iter().next().unwrap(); + /// assert_eq!(sample_set.objectives().get(sample_id), Some(&1.0)); + /// ``` #[tracing::instrument(skip_all)] fn evaluate_samples( &self, @@ -676,7 +732,8 @@ impl Evaluate for Instance { .evaluate_samples(&samples, atol)?; // Objective - let objectives = self.objective().evaluate_samples(&samples, atol)?; + let (sense, output_objective) = self.objective_for_output(); + let objectives = output_objective.evaluate_samples(&samples, atol)?; // Reconstruct decision variable values let mut decision_variables = std::collections::BTreeMap::new(); @@ -696,11 +753,41 @@ impl Evaluate for Instance { .one_hot_constraints_collection(sampled_one_hot_constraints) .sos1_constraints_collection(sampled_sos1_constraints) .named_function_table(named_functions) - .sense(self.sense) + .sense(sense) .feasibility_atol(atol) .build()?) } + /// # Postconditions + /// + /// Partial evaluation rewrites active data while preserving the output objective. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance, + /// Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let variable = VariableID::from(1); + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// let output = instance.output_objective().cloned(); + /// + /// instance + /// .partial_evaluate(&State::from(HashMap::from([(1, 1.0)])), ATol::default()) + /// .unwrap(); + /// assert_eq!(instance.output_objective(), output.as_ref()); + /// assert!(instance.required_ids().is_empty()); + /// let solution = instance.evaluate(&State::default(), ATol::default()).unwrap(); + /// assert_eq!(*solution.objective(), 1.0); + /// ``` #[tracing::instrument(skip_all)] fn partial_evaluate(&mut self, state: &v1::State, atol: ATol) -> Result<()> { if let Some(plan) = PartialEvaluatePlan::prepare(self, state, atol)? { @@ -1364,6 +1451,10 @@ mod tests { .is_none() ); + let mut with_output = instance.clone(); + assert!(with_output.convert_active_objective(Sense::Maximize)); + let output = with_output.output_objective().cloned().unwrap(); + let mut borrowed = instance.clone(); borrowed.partial_evaluate(&state, ATol::default()).unwrap(); let consumed = instance @@ -1381,6 +1472,16 @@ mod tests { (VariableID::from(3), 0.0), ]) ); + + with_output + .partial_evaluate(&state, ATol::default()) + .unwrap(); + assert_eq!(with_output.output_objective(), Some(&output)); + let solution = with_output + .evaluate(&v1::State::default(), ATol::default()) + .unwrap(); + assert_eq!(*solution.sense(), Some(Sense::Minimize)); + assert_eq!(*solution.objective(), 1.0); } #[test] diff --git a/rust/ommx/src/instance/log_encode.rs b/rust/ommx/src/instance/log_encode.rs index dce5c00ce..e8803d5e5 100644 --- a/rust/ommx/src/instance/log_encode.rs +++ b/rust/ommx/src/instance/log_encode.rs @@ -172,6 +172,48 @@ impl Instance { /// [`Self::MAX_LOG_ENCODING_BITS`] binary variables are rejected instead of /// creating an impractically large encoded search space. /// + /// # Postconditions + /// + /// Encoding rewrites only the active formulation and preserves evaluation + /// in the pre-encoding output semantics. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, Bound, DecisionVariable, Evaluate, Function, + /// Instance, Kind, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let variable = VariableID::from(0); + /// let integer = DecisionVariable::new( + /// Kind::Integer, + /// Bound::new(0.0, 3.0).unwrap(), + /// ATol::default(), + /// ) + /// .unwrap(); + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(0))) + /// .decision_variables(BTreeMap::from([(variable, integer)])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// let output = instance.output_objective().cloned(); + /// + /// instance.log_encode([variable], ATol::default()).unwrap(); + /// assert_eq!(instance.output_objective(), output.as_ref()); + /// let encoded_ids = instance.required_ids(); + /// assert_eq!(encoded_ids.len(), 2); + /// let state = State::from(HashMap::from_iter( + /// encoded_ids.into_iter().map(|id| (id.into_inner(), 1.0)), + /// )); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -3.0); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Maximize)); + /// assert_eq!(*solution.objective(), 3.0); + /// ``` + /// /// # Errors /// /// Returns [`LogEncodingUnavailable`] when an otherwise valid Integer @@ -732,6 +774,51 @@ mod tests { assert_eq!(encoded.num_terms(), 4); } + #[test] + fn log_encode_does_not_create_or_rewrite_output_objective() { + let id = VariableID::from(0); + let make_instance = || { + let variable = DecisionVariable::new( + Kind::Integer, + Bound::new(0.0, 3.0).unwrap(), + ATol::default(), + ) + .unwrap(); + Instance::builder() + .sense(Sense::Maximize) + .objective(Function::from(crate::linear!(0))) + .decision_variables(BTreeMap::from([(id, variable)])) + .constraints(BTreeMap::new()) + .build() + .unwrap() + }; + + let mut without_output = make_instance(); + without_output.log_encode([id], ATol::default()).unwrap(); + assert!(without_output.output_objective().is_none()); + + let mut with_output = make_instance(); + assert!(with_output.convert_active_objective(Sense::Minimize)); + let output = with_output.output_objective().cloned().unwrap(); + with_output.log_encode([id], ATol::default()).unwrap(); + + assert_eq!(with_output.output_objective(), Some(&output)); + assert_eq!( + with_output.decision_variable_role(id), + Some(DecisionVariableRole::Dependent) + ); + assert!(!with_output.used_decision_variable_ids().contains(&id)); + let state = State::from_iter( + with_output + .used_decision_variable_ids() + .into_iter() + .map(|id| (id.into_inner(), 0.0)), + ); + let solution = with_output.evaluate(&state, ATol::default()).unwrap(); + assert_eq!(*solution.sense(), Some(Sense::Maximize)); + assert_eq!(*solution.objective(), 0.0); + } + #[test] fn test_log_encoding_coefficients() { // 2^3 case diff --git a/rust/ommx/src/instance/logical_memory.rs b/rust/ommx/src/instance/logical_memory.rs index a4a0c2ea6..afa65334c 100644 --- a/rust/ommx/src/instance/logical_memory.rs +++ b/rust/ommx/src/instance/logical_memory.rs @@ -181,6 +181,7 @@ mod tests { Instance.one_hot_constraint_collection;context;ConstraintContextStore.provenance;FnvHashMap[stack] 32 Instance.one_hot_constraint_collection;one_hot_constraints;BTreeMap[stack] 24 Instance.one_hot_constraint_collection;removed_one_hot_constraints;BTreeMap[stack] 24 + Instance.output_objective;Option[stack] 48 Instance.parameters;Option[stack] 48 Instance.sense 1 Instance.sos1_constraint_collection;context;ConstraintContextStore.labels;ModelingLabelStore.description;FnvHashMap[stack] 32 @@ -258,6 +259,7 @@ mod tests { Instance.one_hot_constraint_collection;context;ConstraintContextStore.provenance;FnvHashMap[stack] 32 Instance.one_hot_constraint_collection;one_hot_constraints;BTreeMap[stack] 24 Instance.one_hot_constraint_collection;removed_one_hot_constraints;BTreeMap[stack] 24 + Instance.output_objective;Option[stack] 48 Instance.parameters;Option[stack] 48 Instance.sense 1 Instance.sos1_constraint_collection;context;ConstraintContextStore.labels;ModelingLabelStore.description;FnvHashMap[stack] 32 @@ -348,6 +350,7 @@ mod tests { Instance.one_hot_constraint_collection;context;ConstraintContextStore.provenance;FnvHashMap[stack] 32 Instance.one_hot_constraint_collection;one_hot_constraints;BTreeMap[stack] 24 Instance.one_hot_constraint_collection;removed_one_hot_constraints;BTreeMap[stack] 24 + Instance.output_objective;Option[stack] 48 Instance.parameters;Option[stack] 48 Instance.sense 1 Instance.sos1_constraint_collection;context;ConstraintContextStore.labels;ModelingLabelStore.description;FnvHashMap[stack] 32 @@ -461,6 +464,7 @@ mod tests { Instance.one_hot_constraint_collection;one_hot_constraints;OneHotConstraint.variables;BTreeSet[stack] 24 Instance.one_hot_constraint_collection;one_hot_constraints;OneHotConstraint.variables;VariableID.0 16 Instance.one_hot_constraint_collection;removed_one_hot_constraints;BTreeMap[stack] 24 + Instance.output_objective;Option[stack] 48 Instance.parameters;Option[stack] 48 Instance.sense 1 Instance.sos1_constraint_collection;context;ConstraintContextStore.labels;ModelingLabelStore.description;FnvHashMap[stack] 32 @@ -570,6 +574,7 @@ mod tests { Instance.one_hot_constraint_collection;context;ConstraintContextStore.provenance;FnvHashMap[stack] 32 Instance.one_hot_constraint_collection;one_hot_constraints;BTreeMap[stack] 24 Instance.one_hot_constraint_collection;removed_one_hot_constraints;BTreeMap[stack] 24 + Instance.output_objective;Option[stack] 48 Instance.parameters;Option[stack] 48 Instance.sense 1 Instance.sos1_constraint_collection;context;ConstraintContextStore.labels;ModelingLabelStore.description;FnvHashMap[stack] 32 @@ -664,6 +669,7 @@ mod tests { Instance.one_hot_constraint_collection;context;ConstraintContextStore.provenance;FnvHashMap[stack] 32 Instance.one_hot_constraint_collection;one_hot_constraints;BTreeMap[stack] 24 Instance.one_hot_constraint_collection;removed_one_hot_constraints;BTreeMap[stack] 24 + Instance.output_objective;Option[stack] 48 Instance.parameters;Parameters.entries 16 Instance.parameters;Parameters.entries;HashMap[key] 16 Instance.parameters;Parameters.entries;HashMap[stack] 48 @@ -677,4 +683,27 @@ mod tests { Instance.sos1_constraint_collection;sos1_constraints;BTreeMap[stack] 24 "###); } + + #[test] + fn output_objective_profile_includes_atomic_pair() { + let mut instance = Instance::new( + crate::Sense::Maximize, + Function::from(linear!(1)), + BTreeMap::from([(VariableID::from(1), DecisionVariable::binary())]), + BTreeMap::new(), + ) + .unwrap(); + assert!(instance.convert_active_objective(crate::Sense::Minimize)); + + let profile = instance.logical_memory_profile(); + assert!(profile + .entries() + .any(|(path, _)| path.contains(&"OutputObjective.sense"))); + assert!(profile + .entries() + .any(|(path, _)| path.contains(&"OutputObjective.function"))); + assert!(profile + .entries() + .any(|(path, _)| path.contains(&"OutputObjective.preserves_optimality"))); + } } diff --git a/rust/ommx/src/instance/parametric_builder.rs b/rust/ommx/src/instance/parametric_builder.rs index 56bddd3ac..93ab9c484 100644 --- a/rust/ommx/src/instance/parametric_builder.rs +++ b/rust/ommx/src/instance/parametric_builder.rs @@ -495,6 +495,7 @@ impl ParametricInstanceBuilder { Ok(ParametricInstance { sense, objective, + output_objective: None, decision_variables, parameters, constraint_collection: ConstraintCollection::with_context( @@ -554,6 +555,7 @@ mod tests { .unwrap(); assert_eq!(*instance.sense(), Sense::Minimize); + assert!(instance.output_objective().is_none()); assert!(instance.decision_variables().is_empty()); assert!(instance.parameters().is_empty()); assert!(instance.constraints().is_empty()); diff --git a/rust/ommx/src/instance/parse.rs b/rust/ommx/src/instance/parse.rs index a7bed00fe..4e71b3cd5 100644 --- a/rust/ommx/src/instance/parse.rs +++ b/rust/ommx/src/instance/parse.rs @@ -75,6 +75,35 @@ fn parse_v2_decision_variable_dependency( .map_err(|e| RawParseError::from(e).context(message, "decision_variable_dependency")) } +fn parse_v2_output_objective( + value: v2::OutputObjective, + allowed_ids: &VariableIDSet, + message: &'static str, +) -> Result { + let sense = crate::v2_io::parse_v2_required_sense(value.sense, message) + .map_err(|error| error.context(message, "output_objective"))?; + let function = value + .function + .ok_or(RawParseError::MissingField { + message, + field: "output_objective.function", + })? + .parse_as(&(), message, "output_objective.function")?; + for id in function.required_ids() { + if !allowed_ids.contains(&id) { + return Err(RawParseError::InvalidInstance(format!( + "Undefined variable ID is used: {id:?}" + )) + .context(message, "output_objective.function")); + } + } + Ok(OutputObjective::new( + sense, + function, + value.preserves_optimality, + )) +} + fn created_collection_has_payload( collection: &ConstraintCollection, ) -> bool { @@ -345,7 +374,6 @@ impl Parse for v1::Instance { .context(message, "objective")); } } - let (constraints, mut constraint_context): ( BTreeMap, crate::ConstraintContextStore, @@ -429,6 +457,7 @@ impl Parse for v1::Instance { Ok(Instance { sense, objective, + output_objective: None, decision_variables, constraint_collection: ConstraintCollection::with_context( constraints, @@ -495,6 +524,17 @@ impl Parse for v2::Instance { .context(message, "objective")); } } + let output_objective = self + .output_objective + .map(|value| parse_v2_output_objective(value, &decision_variable_ids, message)) + .transpose()?; + crate::v2_io::validate_feature_payload( + &required_features, + v2::Feature::OutputObjective, + output_objective.is_some(), + message, + "output_objective", + )?; let constraint_collection = self .regular_constraints @@ -613,9 +653,10 @@ impl Parse for v2::Instance { message, )?; - Ok(Instance { + let mut instance = Instance { sense, objective, + output_objective, decision_variables, constraint_collection, indicator_constraint_collection, @@ -626,7 +667,9 @@ impl Parse for v2::Instance { description: self.description, annotations, named_functions, - }) + }; + instance.canonicalize_output_objective(); + Ok(instance) } } @@ -642,6 +685,7 @@ impl TryFrom for v1::Instance { type Error = crate::Error; fn try_from(value: Instance) -> crate::Result { + value.ensure_no_output_objective("serialization to ommx.v1.Instance")?; let decision_variables: Vec = (&value.decision_variables).into(); let (constraints, removed_constraints): (Vec, Vec) = value.constraint_collection.into(); @@ -829,6 +873,7 @@ impl Parse for v1::ParametricInstance { Ok(ParametricInstance { sense, objective, + output_objective: None, decision_variables, parameters, constraint_collection: ConstraintCollection::with_context( @@ -904,6 +949,17 @@ impl Parse for v2::ParametricInstance { .context(message, "objective")); } } + let output_objective = self + .output_objective + .map(|value| parse_v2_output_objective(value, &all_variable_ids, message)) + .transpose()?; + crate::v2_io::validate_feature_payload( + &required_features, + v2::Feature::OutputObjective, + output_objective.is_some(), + message, + "output_objective", + )?; let constraint_collection = self .regular_constraints @@ -1022,9 +1078,10 @@ impl Parse for v2::ParametricInstance { message, )?; - Ok(ParametricInstance { + let mut instance = ParametricInstance { sense, objective, + output_objective, decision_variables, parameters, constraint_collection, @@ -1035,7 +1092,9 @@ impl Parse for v2::ParametricInstance { decision_variable_dependency, description: self.description, annotations, - }) + }; + instance.canonicalize_output_objective(); + Ok(instance) } } @@ -1054,6 +1113,7 @@ impl TryFrom for v1::ParametricInstance { ParametricInstance { sense, objective, + output_objective, decision_variables, parameters, constraint_collection, @@ -1066,6 +1126,11 @@ impl TryFrom for v1::ParametricInstance { annotations, }: ParametricInstance, ) -> crate::Result { + if output_objective.is_some() { + crate::bail!( + "serialization to ommx.v1.ParametricInstance cannot preserve ParametricInstance.output_objective" + ); + } // Special constraint types do not have a v1 proto representation yet. if !indicator_constraint_collection.active().is_empty() || !indicator_constraint_collection.removed().is_empty() diff --git a/rust/ommx/src/instance/penalty.rs b/rust/ommx/src/instance/penalty.rs index 88c0abf33..4a4604778 100644 --- a/rust/ommx/src/instance/penalty.rs +++ b/rust/ommx/src/instance/penalty.rs @@ -74,8 +74,44 @@ impl Instance { /// $$ /// /// where $\lambda_1$ and $\lambda_2$ are penalty parameters. - pub fn penalty_method(self) -> Result { + /// + /// # Postconditions + /// + /// The transformed objective contains parameterized penalties and preserves the entry objective for output. + /// + /// ``` + /// use ommx::{ + /// linear, Constraint, ConstraintID, DecisionVariable, Function, Instance, + /// Sense, VariableID, + /// }; + /// use std::collections::BTreeMap; + /// + /// let variable = VariableID::from(1); + /// let original = Function::from(linear!(1)); + /// let instance = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(original.clone()) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(Function::from(linear!(1))), + /// )])) + /// .build() + /// .unwrap(); + /// + /// let parametric = instance.penalty_method().unwrap(); + /// assert!(parametric.constraints().is_empty()); + /// assert_eq!(parametric.parameters().len(), 1); + /// let output = parametric.output_objective().unwrap(); + /// assert_eq!(output.sense(), Sense::Minimize); + /// assert_eq!(output.function(), &original); + /// assert!(!output.preserves_optimality()); + /// ``` + pub fn penalty_method(mut self) -> Result { self.ensure_penalty_method_supported("penalty_method")?; + if !self.constraints().is_empty() { + self.invalidate_output_objective_optimality(); + } let mut max_id = 0; @@ -134,6 +170,7 @@ impl Instance { Ok(ParametricInstance { sense: self.sense, objective, + output_objective: self.output_objective, decision_variables: self.decision_variables, parameters, constraint_collection, @@ -173,6 +210,44 @@ impl Instance { /// regular-constraint lifecycle changes are completed on local values and /// committed only after both succeed. /// + /// # Postconditions + /// + /// Fixed penalties change the active objective while preserving the entry objective for output. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, Constraint, ConstraintID, DecisionVariable, + /// Evaluate, Function, Instance, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let variable = VariableID::from(1); + /// let original = Function::from(linear!(1)); + /// let mut instance = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(original.clone()) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(Function::from(linear!(1))), + /// )])) + /// .build() + /// .unwrap(); + /// + /// instance + /// .penalty_method_with_fixed_weights( + /// &BTreeMap::from([(ConstraintID::from(1), 2.0)]), + /// ATol::default(), + /// ) + /// .unwrap(); + /// let state = State::from(HashMap::from([(1, 1.0)])); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), 3.0); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.objective(), 1.0); + /// assert_eq!(instance.output_objective().unwrap().function(), &original); + /// assert!(!instance.output_objective().unwrap().preserves_optimality()); + /// ``` + /// /// # Errors /// /// Returns an error if an unsupported active special constraint is present, @@ -243,7 +318,40 @@ impl Instance { /// $$ /// /// where $\lambda$ is the single penalty parameter. - pub fn uniform_penalty_method(self) -> Result { + /// + /// # Postconditions + /// + /// The transformed objective contains one shared penalty parameter and preserves the entry objective for output. + /// + /// ``` + /// use ommx::{ + /// linear, Constraint, ConstraintID, DecisionVariable, Function, Instance, + /// Sense, VariableID, + /// }; + /// use std::collections::BTreeMap; + /// + /// let variable = VariableID::from(1); + /// let original = Function::from(linear!(1)); + /// let instance = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(original.clone()) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(Function::from(linear!(1))), + /// )])) + /// .build() + /// .unwrap(); + /// + /// let parametric = instance.uniform_penalty_method().unwrap(); + /// assert!(parametric.constraints().is_empty()); + /// assert_eq!(parametric.parameters().len(), 1); + /// let output = parametric.output_objective().unwrap(); + /// assert_eq!(output.sense(), Sense::Minimize); + /// assert_eq!(output.function(), &original); + /// assert!(!output.preserves_optimality()); + /// ``` + pub fn uniform_penalty_method(mut self) -> Result { self.ensure_penalty_method_supported("uniform_penalty_method")?; // Early return if no active constraints (preserve any existing removed constraints) @@ -251,6 +359,7 @@ impl Instance { return Ok(ParametricInstance { sense: self.sense, objective: self.objective, + output_objective: self.output_objective, decision_variables: self.decision_variables, parameters: ParameterTable::default(), constraint_collection: self.constraint_collection, @@ -264,6 +373,8 @@ impl Instance { }); } + self.invalidate_output_objective_optimality(); + let mut max_id = 0; // Find the maximum ID among decision variables @@ -312,6 +423,7 @@ impl Instance { Ok(ParametricInstance { sense: self.sense, objective, + output_objective: self.output_objective, decision_variables: self.decision_variables, parameters, constraint_collection, @@ -350,6 +462,41 @@ impl Instance { /// lifecycle changes are completed on local values and committed only after /// both succeed. /// + /// # Postconditions + /// + /// The uniform fixed penalty changes the active objective while preserving the entry objective for output. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, Constraint, ConstraintID, DecisionVariable, + /// Evaluate, Function, Instance, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let variable = VariableID::from(1); + /// let original = Function::from(linear!(1)); + /// let mut instance = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(original.clone()) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(Function::from(linear!(1))), + /// )])) + /// .build() + /// .unwrap(); + /// + /// instance + /// .uniform_penalty_method_with_fixed_weight(2.0, ATol::default()) + /// .unwrap(); + /// let state = State::from(HashMap::from([(1, 1.0)])); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), 3.0); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.objective(), 1.0); + /// assert_eq!(instance.output_objective().unwrap().function(), &original); + /// assert!(!instance.output_objective().unwrap().preserves_optimality()); + /// ``` + /// /// # Errors /// /// Returns an error if an unsupported active special constraint is present, @@ -456,6 +603,9 @@ impl Instance { let mut constraint_collection = self.constraint_collection.clone(); constraint_collection.move_active_rows_to_removed(removals)?; + if !self.constraint_collection.active().is_empty() || objective != self.objective { + self.invalidate_output_objective_optimality(); + } self.objective = objective; self.constraint_collection = constraint_collection; Ok(()) @@ -467,7 +617,7 @@ mod tests { use super::*; use crate::{ coeff, constraint::Equality, linear, quadratic, v1::State, ATol, ConstraintContext, - DecisionVariable, Evaluate, ModelingLabel, Sense, + DecisionVariable, Evaluate, ModelingLabel, Sampled, Sense, }; use std::collections::{BTreeMap, BTreeSet}; @@ -578,6 +728,42 @@ mod tests { } } + fn assert_penalty_output_semantics( + instance: &Instance, + expected_active: f64, + expected_output: f64, + ) { + let state = State::from_iter([(1, 2.0), (2, 1.0)]); + let output = instance.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Minimize); + assert!(!output.preserves_optimality()); + assert_eq!( + output.function().evaluate(&state, ATol::default()).unwrap(), + expected_output + ); + assert_eq!( + instance + .objective() + .evaluate(&state, ATol::default()) + .unwrap(), + expected_active + ); + + let solution = instance.evaluate(&state, ATol::default()).unwrap(); + assert_eq!(*solution.sense(), Some(Sense::Minimize)); + assert_eq!(*solution.objective(), expected_output); + + let sample_set = instance + .evaluate_samples(&Sampled::from(state), ATol::default()) + .unwrap(); + let sample_id = sample_set.sample_ids().into_iter().next().unwrap(); + assert_eq!(*sample_set.sense(), Sense::Minimize); + assert_eq!( + sample_set.objectives().get(sample_id), + Some(&expected_output) + ); + } + /// Helper function to verify penalty method properties fn verify_penalty_method_properties( original_objective: Function, @@ -614,6 +800,11 @@ mod tests { parametric_instance.parameters().keys().cloned().collect(); assert!(dv_ids.is_disjoint(&p_ids)); + let output = parametric_instance.output_objective().unwrap(); + assert_eq!(output.sense(), parametric_instance.sense); + assert_eq!(output.function(), &original_objective); + assert!(!output.preserves_optimality()); + // Verify zero penalty weight behavior use crate::v1::Parameters; use ::approx::AbsDiffEq; @@ -629,6 +820,10 @@ mod tests { assert!(substituted .objective .abs_diff_eq(&original_objective, crate::ATol::default())); + let output = substituted.output_objective().unwrap(); + assert_eq!(output.sense(), substituted.sense); + assert_eq!(output.function(), &original_objective); + assert!(!output.preserves_optimality()); assert_eq!(substituted.constraints().len(), 0); } @@ -696,6 +891,25 @@ mod tests { assert_eq!(parametric_instance.objective, objective); } + #[test] + fn no_constraint_penalty_preserves_existing_output_objective() { + let mut instance = Instance::new( + Sense::Minimize, + Function::from(linear!(1)), + BTreeMap::from([(VariableID::from(1), DecisionVariable::continuous())]), + BTreeMap::new(), + ) + .unwrap(); + assert!(instance.convert_active_objective(Sense::Maximize)); + let output = instance.output_objective().cloned().unwrap(); + + let keyed = instance.clone().penalty_method().unwrap(); + assert_eq!(keyed.output_objective(), Some(&output)); + + let uniform = instance.uniform_penalty_method().unwrap(); + assert_eq!(uniform.output_objective(), Some(&output)); + } + #[test] fn test_penalty_method_preserves_existing_removed_constraints() { let mut instance = create_test_instance_with_constraints(); @@ -893,6 +1107,7 @@ mod tests { let mut uniform = create_test_instance_with_constraints(); uniform.sense = Sense::Maximize; + let uniform_original = uniform.objective().clone(); uniform .uniform_penalty_method_with_fixed_weight(2.0, ATol::default()) .unwrap(); @@ -903,9 +1118,14 @@ mod tests { .unwrap(), -7.0 ); + let uniform_output = uniform.output_objective().unwrap(); + assert_eq!(uniform_output.sense(), Sense::Maximize); + assert_eq!(uniform_output.function(), &uniform_original); + assert!(!uniform_output.preserves_optimality()); let mut keyed = create_test_instance_with_constraints(); keyed.sense = Sense::Maximize; + let keyed_original = keyed.objective().clone(); keyed .penalty_method_with_fixed_weights( &BTreeMap::from([(ConstraintID::from(1), 2.0), (ConstraintID::from(2), 3.0)]), @@ -916,6 +1136,79 @@ mod tests { keyed.objective().evaluate(&state, ATol::default()).unwrap(), -8.0 ); + let keyed_output = keyed.output_objective().unwrap(); + assert_eq!(keyed_output.sense(), Sense::Maximize); + assert_eq!(keyed_output.function(), &keyed_original); + assert!(!keyed_output.preserves_optimality()); + } + + #[test] + fn fixed_penalty_preserves_existing_output_objective() { + let mut instance = create_test_instance_with_constraints(); + instance.sense = Sense::Maximize; + let original_objective = instance.objective().clone(); + assert!(instance.convert_active_objective(Sense::Minimize)); + + instance + .uniform_penalty_method_with_fixed_weight(2.0, ATol::default()) + .unwrap(); + + let output = instance.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Maximize); + assert_eq!(output.function(), &original_objective); + assert!(!output.preserves_optimality()); + let solution = instance + .evaluate(&State::from_iter([(1, 2.0), (2, 1.0)]), ATol::default()) + .unwrap(); + assert_eq!(*solution.sense(), Some(Sense::Maximize)); + assert_eq!(*solution.objective(), 3.0); + } + + #[test] + fn direct_fixed_penalty_preserves_the_original_objective_even_at_zero_weight() { + let mut instance = create_test_instance_with_constraints(); + let original_objective = instance.objective().clone(); + + instance + .uniform_penalty_method_with_fixed_weight(0.0, ATol::default()) + .unwrap(); + + let output = instance.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Minimize); + assert_eq!(output.function(), &original_objective); + assert!(!output.preserves_optimality()); + } + + #[test] + fn parametric_penalty_methods_preserve_existing_output_objective() { + let mut instance = create_test_instance_with_constraints(); + instance.sense = Sense::Maximize; + let original_objective = instance.objective().clone(); + assert!(instance.convert_active_objective(Sense::Minimize)); + + for parametric in [ + instance.clone().penalty_method().unwrap(), + instance.uniform_penalty_method().unwrap(), + ] { + let output = parametric.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Maximize); + assert_eq!(output.function(), &original_objective); + assert!(!output.preserves_optimality()); + + let parameters = crate::v1::Parameters { + entries: parametric + .parameters() + .keys() + .map(|id| (id.into_inner(), 2.0)) + .collect(), + }; + let materialized = parametric.with_parameters(parameters).unwrap(); + let solution = materialized + .evaluate(&State::from_iter([(1, 2.0), (2, 1.0)]), ATol::default()) + .unwrap(); + assert_eq!(*solution.sense(), Some(Sense::Maximize)); + assert_eq!(*solution.objective(), 3.0); + } } #[test] @@ -1039,14 +1332,7 @@ mod tests { Some("penalized") ); assert_fixed_penalty_removal_provenance(&instance, "penalty_method_with_fixed_weights"); - let state = State::from_iter([(1, 2.0), (2, 1.0)]); - assert_eq!( - instance - .objective() - .evaluate(&state, ATol::default()) - .unwrap(), - 14.0 - ); + assert_penalty_output_semantics(&instance, 14.0, 3.0); let state = State::from_iter([(1, 0.0), (2, 0.0)]); assert_eq!( instance diff --git a/rust/ommx/src/instance/preparation.rs b/rust/ommx/src/instance/preparation.rs index 6929aebf3..1062a7580 100644 --- a/rust/ommx/src/instance/preparation.rs +++ b/rust/ommx/src/instance/preparation.rs @@ -1,5 +1,5 @@ use super::{ExactIntegerSlackUnavailable, Instance, SpecialConstraintKinds}; -use crate::{ATol, ConstraintID, Equality, InstanceClass, InstanceClassMembershipReport}; +use crate::{ATol, ConstraintID, Equality, InstanceClass, InstanceClassMembershipReport, Sense}; use std::collections::BTreeMap; /// Preparation of active special constraints. @@ -16,14 +16,13 @@ pub enum SpecialConstraintPreparation { }, } -/// Preparation of the optimization sense. +/// Preparation of the active objective used by the solver-facing formulation. /// -/// Each variant selects one existing [`Instance`] owner operation. -#[non_exhaustive] +/// The conversion itself remains owned by [`Instance::convert_active_objective`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SensePreparation { - /// Invoke [`Instance::as_minimization_problem`]. - AsMinimizationProblem, +pub struct ObjectivePreparation { + /// Target optimization sense passed to [`Instance::convert_active_objective`]. + pub target: Sense, } /// Preparation that introduces Integer slack variables into active regular @@ -72,10 +71,18 @@ pub enum IntegerEncodingPreparation { }, } +/// Preparation that reduces powers of active Binary decision variables. +/// +/// This unit phase invokes [`Instance::reduce_binary_power`]. Existing output +/// semantics are retained by the enclosing [`Instance::prepare`] operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BinaryPowerPreparation; + /// Fixed-weight penalty preparation of active regular constraints. /// /// Exactly one fixed-weight penalty owner operation is selected. Weight and -/// constraint-ID validation remains owned by that operation. +/// constraint-ID validation and output-objective preservation remain owned by +/// that operation. #[non_exhaustive] #[derive(Debug, Clone, PartialEq)] pub enum FixedPenaltyPreparation { @@ -108,46 +115,143 @@ pub enum FixedPenaltyPreparation { /// phase is disabled by default, including fields added in future releases. /// /// [`Instance::prepare`] applies each selected phase at most once in this -/// canonical order: special constraints, optimization sense, Integer slack, -/// Integer encoding, then fixed penalty. It checks whole-class membership -/// before and after each selected phase and stops as soon as the target class -/// contains the instance. +/// canonical order: special constraints, active objective, Integer slack, +/// fixed penalty, Integer encoding, then Binary-power reduction. It checks +/// whole-class membership before and after each selected phase and stops as +/// soon as the target class contains the instance. +/// +/// # Invariants /// -/// # Examples +/// Every Preparation phase is disabled by default. /// /// ``` -/// use ommx::{ -/// ATol, FixedPenaltyPreparation, IntegerSlackPreparation, PreparationPolicy, -/// SensePreparation, -/// }; +/// use ommx::PreparationPolicy; /// -/// let mut policy = PreparationPolicy::default(); -/// policy.sense = Some(SensePreparation::AsMinimizationProblem); -/// policy.integer_slack = Some(IntegerSlackPreparation { -/// max_integer_range: 32, -/// atol: ATol::default(), -/// slack_upper_bound: None, -/// }); -/// policy.fixed_penalty = Some( -/// FixedPenaltyPreparation::UniformPenaltyMethodWithFixedWeight { -/// weight: 2.0, -/// atol: ATol::default(), -/// }, -/// ); +/// let policy = PreparationPolicy::default(); +/// assert!(policy.special_constraints.is_none()); +/// assert!(policy.objective.is_none()); +/// assert!(policy.integer_slack.is_none()); +/// assert!(policy.integer_encoding.is_none()); +/// assert!(policy.fixed_penalty.is_none()); +/// assert!(policy.binary_power_reduction.is_none()); /// ``` #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Default)] pub struct PreparationPolicy { /// Optional special-constraint phase. pub special_constraints: Option, - /// Optional optimization-sense phase. - pub sense: Option, + /// Optional active-objective phase. + pub objective: Option, /// Optional Integer slack phase. `None` does not introduce Integer slack. pub integer_slack: Option, /// Optional used-Integer encoding phase. pub integer_encoding: Option, /// Optional fixed-weight penalty phase. pub fixed_penalty: Option, + /// Optional Binary-power reduction phase. + pub binary_power_reduction: Option, +} + +impl PreparationPolicy { + /// Return a fresh default policy for reaching [`InstanceClass::qubo`]. + /// + /// # Postconditions + /// + /// The returned QUBO policy enables every default preparation phase. + /// + /// ``` + /// use ommx::{ + /// BinaryPowerPreparation, FixedPenaltyPreparation, + /// IntegerEncodingPreparation, ObjectivePreparation, PreparationPolicy, + /// Sense, SpecialConstraintPreparation, + /// }; + /// + /// let policy = PreparationPolicy::for_qubo(); + /// assert!(matches!( + /// policy.special_constraints, + /// Some(SpecialConstraintPreparation::LowerSpecialConstraints { .. }), + /// )); + /// assert_eq!( + /// policy.objective, + /// Some(ObjectivePreparation { target: Sense::Minimize }), + /// ); + /// let slack = policy.integer_slack.unwrap(); + /// assert_eq!(slack.max_integer_range, 31); + /// assert_eq!(slack.slack_upper_bound, Some(31)); + /// assert!(matches!( + /// policy.integer_encoding, + /// Some(IntegerEncodingPreparation::LogEncodeAllUsedIntegers { .. }), + /// )); + /// assert!(matches!( + /// policy.fixed_penalty, + /// Some(FixedPenaltyPreparation::UniformPenaltyMethodWithFixedWeight { + /// weight: 1.0, + /// .. + /// }), + /// )); + /// assert_eq!(policy.binary_power_reduction, Some(BinaryPowerPreparation)); + /// ``` + pub fn for_qubo() -> Self { + Self::for_binary_polynomial_format(Some(BinaryPowerPreparation)) + } + + /// Return a fresh default policy for reaching [`InstanceClass::hubo`]. + /// + /// # Postconditions + /// + /// The returned HUBO policy leaves Binary-power reduction disabled. + /// + /// ``` + /// use ommx::{ObjectivePreparation, PreparationPolicy, Sense}; + /// + /// let policy = PreparationPolicy::for_hubo(); + /// assert!(policy.special_constraints.is_some()); + /// assert_eq!( + /// policy.objective, + /// Some(ObjectivePreparation { target: Sense::Minimize }), + /// ); + /// assert!(policy.integer_slack.is_some()); + /// assert!(policy.integer_encoding.is_some()); + /// assert!(policy.fixed_penalty.is_some()); + /// assert!(policy.binary_power_reduction.is_none()); + /// ``` + pub fn for_hubo() -> Self { + Self::for_binary_polynomial_format(None) + } + + fn for_binary_polynomial_format( + binary_power_reduction: Option, + ) -> Self { + Self { + special_constraints: Some(SpecialConstraintPreparation::LowerSpecialConstraints { + kinds: [ + super::SpecialConstraintKind::Indicator, + super::SpecialConstraintKind::OneHot, + super::SpecialConstraintKind::Sos1, + ] + .into_iter() + .collect(), + }), + objective: Some(ObjectivePreparation { + target: Sense::Minimize, + }), + integer_slack: Some(IntegerSlackPreparation { + max_integer_range: 31, + atol: ATol::default(), + slack_upper_bound: Some(31), + }), + integer_encoding: Some(IntegerEncodingPreparation::LogEncodeAllUsedIntegers { + atol: ATol::default(), + }), + fixed_penalty: Some( + FixedPenaltyPreparation::UniformPenaltyMethodWithFixedWeight { + weight: 1.0, + atol: ATol::default(), + }, + ), + binary_power_reduction, + } + } } /// Signal returned when configured Preparation phases are exhausted before the @@ -186,13 +290,9 @@ impl PreparationStep for SpecialConstraintPreparation { } } -impl PreparationStep for SensePreparation { +impl PreparationStep for ObjectivePreparation { fn apply(&self, instance: &mut Instance) -> crate::Result<()> { - match self { - Self::AsMinimizationProblem => { - instance.as_minimization_problem(); - } - } + instance.convert_active_objective(self.target); Ok(()) } } @@ -252,6 +352,13 @@ impl PreparationStep for FixedPenaltyPreparation { } } +impl PreparationStep for BinaryPowerPreparation { + fn apply(&self, instance: &mut Instance) -> crate::Result<()> { + instance.reduce_binary_power()?; + Ok(()) + } +} + fn apply_preparation_step( instance: &mut Instance, input_class: &InstanceClass, @@ -289,6 +396,46 @@ impl Instance { /// own mutation and failure semantics, and changes committed by earlier /// operations remain when a later operation fails. /// + /// # Postconditions + /// + /// Successful preparation reaches the target class while retaining output semantics. + /// + /// ``` + /// use ommx::{ + /// coeff, linear, v1::{Optimality, State}, ATol, Constraint, ConstraintID, + /// DecisionVariable, Evaluate, Function, Instance, InstanceClass, + /// PreparationPolicy, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let variable = VariableID::from(1); + /// let constraint = Function::from((linear!(1) + coeff!(-1.0)).unwrap()); + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(constraint), + /// )])) + /// .build() + /// .unwrap(); + /// let target = InstanceClass::qubo(); + /// + /// instance.prepare(&target, &PreparationPolicy::for_qubo()).unwrap(); + /// assert!(target.contains(&instance)); + /// assert_eq!(instance.sense(), Sense::Minimize); + /// let state = State::from(HashMap::from([(1, 0.0)])); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), 1.0); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Maximize)); + /// assert_eq!(*solution.objective(), 0.0); + /// assert_eq!( + /// instance.map_active_optimality(Optimality::Optimal), + /// Optimality::Unspecified, + /// ); + /// ``` + /// /// # Errors /// /// Returns an error from a configured owner operation, or @@ -299,6 +446,16 @@ impl Instance { &mut self, input_class: &InstanceClass, policy: &PreparationPolicy, + ) -> crate::Result<()> { + self.preserve_output_objective_during_preparation(|instance| { + instance.prepare_phases(input_class, policy) + }) + } + + fn prepare_phases( + &mut self, + input_class: &InstanceClass, + policy: &PreparationPolicy, ) -> crate::Result<()> { if input_class.contains(self) { return Ok(()); @@ -308,7 +465,7 @@ impl Instance { return Ok(()); } - if apply_preparation_step(self, input_class, policy.sense.as_ref())? { + if apply_preparation_step(self, input_class, policy.objective.as_ref())? { return Ok(()); } @@ -316,11 +473,15 @@ impl Instance { return Ok(()); } + if apply_preparation_step(self, input_class, policy.fixed_penalty.as_ref())? { + return Ok(()); + } + if apply_preparation_step(self, input_class, policy.integer_encoding.as_ref())? { return Ok(()); } - if apply_preparation_step(self, input_class, policy.fixed_penalty.as_ref())? { + if apply_preparation_step(self, input_class, policy.binary_power_reduction.as_ref())? { return Ok(()); } @@ -333,9 +494,10 @@ impl Instance { mod tests { use super::*; use crate::{ - coeff, linear, quadratic, Bound, Constraint, DecisionVariable, DegreeBound, - ExactIntegerSlackUnavailable, Function, InfeasibleDetected, InstanceClassClause, Kind, - OneHotConstraint, OneHotConstraintID, Sense, SpecialConstraintKind, VariableID, + coeff, linear, quadratic, Bound, Constraint, DecisionVariable, DecisionVariableRole, + DegreeBound, Evaluate, ExactIntegerSlackUnavailable, Function, InfeasibleDetected, + InstanceClassClause, Kind, MonomialDyn, OneHotConstraint, OneHotConstraintID, Polynomial, + Sense, SpecialConstraintKind, VariableID, }; use std::collections::BTreeSet; @@ -374,6 +536,144 @@ mod tests { .unwrap() } + fn cubic_binary_instance(ids: Vec) -> Instance { + let variables = ids + .iter() + .copied() + .map(|id| (id, DecisionVariable::binary())) + .collect(); + let objective = Function::from(Polynomial::single_term(MonomialDyn::new(ids), coeff!(1.0))); + Instance::new(Sense::Minimize, objective, variables, BTreeMap::new()).unwrap() + } + + #[test] + fn qubo_preparation_reduces_repeated_binary_power() { + let variable = VariableID::from(1); + let mut instance = cubic_binary_instance(vec![variable, variable, variable]); + let source_objective = instance.objective().clone(); + + instance + .prepare(&InstanceClass::qubo(), &PreparationPolicy::for_qubo()) + .unwrap(); + + assert!(InstanceClass::qubo().contains(&instance)); + assert_eq!(instance.objective().degree(), crate::Degree::from(1)); + assert_eq!( + instance.objective().required_ids(), + crate::VariableIDSet::from([variable]) + ); + let output = instance.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Minimize); + assert_eq!(output.function(), &source_objective); + assert!(output.preserves_optimality()); + instance.as_qubo_format().unwrap(); + } + + #[test] + fn qubo_rejects_but_hubo_accepts_three_distinct_binary_variables() { + let ids = vec![ + VariableID::from(1), + VariableID::from(2), + VariableID::from(3), + ]; + let source = cubic_binary_instance(ids); + + let mut qubo = source.clone(); + let error = qubo + .prepare(&InstanceClass::qubo(), &PreparationPolicy::for_qubo()) + .unwrap_err(); + assert!(error.is::()); + assert!(!InstanceClass::qubo().contains(&qubo)); + assert!(qubo.output_objective().is_none()); + + let mut hubo = source; + hubo.prepare(&InstanceClass::hubo(), &PreparationPolicy::for_hubo()) + .unwrap(); + assert!(InstanceClass::hubo().contains(&hubo)); + assert!(hubo.output_objective().is_none()); + hubo.as_hubo_format().unwrap(); + } + + #[test] + fn qubo_preparation_preserves_entry_objective_and_pre_encoding_constraint() { + let variable = VariableID::from(1); + let constraint_id = ConstraintID::from(7); + let objective = Function::from(linear!(variable)); + let constraint_function = (Function::from(linear!(variable)) + coeff!(-1.0)).unwrap(); + let mut instance = Instance::new( + Sense::Minimize, + objective.clone(), + BTreeMap::from([( + variable, + DecisionVariable::new( + Kind::Integer, + Bound::new(0.0, 3.0).unwrap(), + ATol::default(), + ) + .unwrap(), + )]), + BTreeMap::from([( + constraint_id, + Constraint::equal_to_zero(constraint_function.clone()), + )]), + ) + .unwrap(); + + instance + .prepare(&InstanceClass::qubo(), &PreparationPolicy::for_qubo()) + .unwrap(); + + assert!(InstanceClass::qubo().contains(&instance)); + let output = instance.output_objective().unwrap(); + assert_eq!(output.sense(), Sense::Minimize); + assert_eq!(output.function(), &objective); + assert!(!output.preserves_optimality()); + assert_eq!( + instance.removed_constraints()[&constraint_id].0.function(), + &constraint_function + ); + assert_eq!( + instance.decision_variable_role(variable), + Some(DecisionVariableRole::Dependent) + ); + } + + #[test] + fn prepare_error_canonicalizes_redundant_entry_output_objective() { + let variable = VariableID::from(1); + let mut instance = Instance::new( + Sense::Minimize, + Function::from(linear!(variable)), + BTreeMap::from([(variable, DecisionVariable::binary())]), + BTreeMap::from([( + ConstraintID::from(1), + Constraint::equal_to_zero(Function::from(linear!(variable))), + )]), + ) + .unwrap(); + let before = instance.clone(); + let policy = PreparationPolicy { + objective: Some(ObjectivePreparation { + target: Sense::Minimize, + }), + fixed_penalty: Some( + FixedPenaltyPreparation::UniformPenaltyMethodWithFixedWeight { + weight: -1.0, + atol: ATol::default(), + }, + ), + ..Default::default() + }; + + let error = instance + .prepare(&InstanceClass::qubo(), &policy) + .unwrap_err(); + + assert!(error.is::()); + assert_eq!(instance, before); + assert!(instance.output_objective().is_none()); + } + #[test] fn already_member_is_exact_identity() { let mut instance = Instance::new( @@ -573,7 +873,9 @@ mod tests { special_constraints: Some(SpecialConstraintPreparation::LowerSpecialConstraints { kinds: BTreeSet::from([SpecialConstraintKind::OneHot]), }), - sense: Some(SensePreparation::AsMinimizationProblem), + objective: Some(ObjectivePreparation { + target: Sense::Minimize, + }), integer_slack: Some(IntegerSlackPreparation { max_integer_range: 32, atol: ATol::default(), @@ -616,7 +918,9 @@ mod tests { DegreeBound::at_most(1), ); let policy = PreparationPolicy { - sense: Some(SensePreparation::AsMinimizationProblem), + objective: Some(ObjectivePreparation { + target: Sense::Minimize, + }), ..Default::default() }; diff --git a/rust/ommx/src/instance/qubo.rs b/rust/ommx/src/instance/qubo.rs index f26068557..d2d315c98 100644 --- a/rust/ommx/src/instance/qubo.rs +++ b/rust/ommx/src/instance/qubo.rs @@ -14,6 +14,39 @@ impl Instance { /// - TODO: Binary encoding will be added. /// - The degree of the objective is at most 2. /// + /// # Postconditions + /// + /// The returned QUBO encodes the active objective, while evaluation uses the output objective. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, BinaryIdPair, DecisionVariable, Evaluate, + /// Function, Instance, Sense, VariableID, + /// }; + /// use std::collections::BTreeMap; + /// + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// + /// let (qubo, offset) = instance.as_qubo_format().unwrap(); + /// assert_eq!(qubo.get(&BinaryIdPair(1, 1)), Some(&-1.0)); + /// assert_eq!(offset, 0.0); + /// + /// let solution = instance + /// .evaluate(&State::from_iter([(1, 1.0)]), ATol::default()) + /// .unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Maximize)); + /// assert_eq!(*solution.objective(), 1.0); + /// ``` #[tracing::instrument(skip_all)] pub fn as_qubo_format(&self) -> Result<(BTreeMap, f64)> { if self.sense() == Sense::Maximize { @@ -64,6 +97,40 @@ impl Instance { /// - The objective function uses only binary decision variables. /// - TODO: Binary encoding will be added. /// + /// # Postconditions + /// + /// The returned HUBO encodes the active objective, while evaluation uses the output objective. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, DecisionVariable, Evaluate, Function, + /// Instance, Sense, VariableID, + /// }; + /// use std::collections::BTreeMap; + /// + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// + /// let (hubo, offset) = instance.as_hubo_format().unwrap(); + /// assert_eq!(hubo.len(), 1); + /// assert_eq!(hubo.values().next(), Some(&-1.0)); + /// assert_eq!(offset, 0.0); + /// + /// let solution = instance + /// .evaluate(&State::from_iter([(1, 1.0)]), ATol::default()) + /// .unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Maximize)); + /// assert_eq!(*solution.objective(), 1.0); + /// ``` #[tracing::instrument(skip_all)] pub fn as_hubo_format(&self) -> Result<(BTreeMap, f64)> { if self.sense() == Sense::Maximize { diff --git a/rust/ommx/src/instance/reduce_binary_power.rs b/rust/ommx/src/instance/reduce_binary_power.rs index 3d166e76a..d4ecaee39 100644 --- a/rust/ommx/src/instance/reduce_binary_power.rs +++ b/rust/ommx/src/instance/reduce_binary_power.rs @@ -7,7 +7,38 @@ impl Instance { /// This method replaces binary powers in the instance with their equivalent linear expressions. /// For binary variables, x^n = x for any n >= 1, so we can reduce higher powers to linear terms. /// - /// Returns `true` if any reduction was performed, `false` otherwise. + /// # Postconditions + /// + /// Reduction rewrites active functions while preserving the output objective. + /// + /// ``` + /// use ommx::{ + /// quadratic, v1::State, ATol, DecisionVariable, Evaluate, Function, Instance, + /// Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(quadratic!(1, 1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// let output = instance.output_objective().cloned(); + /// + /// assert!(instance.reduce_binary_power().unwrap()); + /// assert_eq!(instance.output_objective(), output.as_ref()); + /// assert!(!instance.reduce_binary_power().unwrap()); + /// let state = State::from(HashMap::from([(1, 1.0)])); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -1.0); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.objective(), 1.0); + /// ``` pub fn reduce_binary_power(&mut self) -> Result { let binary_ids = self.binary_ids(); if binary_ids.is_empty() { diff --git a/rust/ommx/src/instance/serialize.rs b/rust/ommx/src/instance/serialize.rs index 13ea7b167..34b102cd6 100644 --- a/rust/ommx/src/instance/serialize.rs +++ b/rust/ommx/src/instance/serialize.rs @@ -3,11 +3,62 @@ use crate::{message_io, v1, v2, ConstraintType, Message, Parse}; use anyhow::Result; impl Instance { + /// Serialize this instance using the v1 wire format. + /// + /// # Errors + /// + /// v1 serialization rejects instances with a distinct output objective. + /// + /// ``` + /// use ommx::{linear, DecisionVariable, Function, Instance, Sense, VariableID}; + /// use std::collections::BTreeMap; + /// + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// + /// assert!(instance.to_v1_bytes().is_err()); + /// ``` pub fn to_v1_bytes(&self) -> Result> { let v1_instance = v1::Instance::try_from(self.clone())?; Ok(v1_instance.encode_to_vec()) } + /// Serialize this instance using the v2 wire format. + /// + /// # Postconditions + /// + /// v2 serialization round-trips the output objective. + /// + /// ``` + /// use ommx::{linear, DecisionVariable, Function, Instance, Sense, VariableID}; + /// use std::collections::BTreeMap; + /// + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// + /// let restored = Instance::from_v2_bytes(&instance.to_v2_bytes()).unwrap(); + /// assert_eq!(restored.sense(), Sense::Minimize); + /// assert_eq!(restored.output_objective().unwrap().sense(), Sense::Maximize); + /// assert_eq!(restored, instance); + /// ``` pub fn to_v2_bytes(&self) -> Vec { let v2_instance = v2::Instance::from(self.clone()); v2_instance.encode_to_vec() @@ -25,11 +76,63 @@ impl Instance { } impl ParametricInstance { + /// Serialize this parametric instance using the v1 wire format. + /// + /// # Errors + /// + /// v1 serialization rejects parametric instances with a distinct output objective. + /// + /// ``` + /// use ommx::{linear, DecisionVariable, Function, Instance, ParametricInstance, Sense, VariableID}; + /// use std::collections::BTreeMap; + /// + /// let mut source = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(source.convert_active_objective(Sense::Minimize)); + /// let instance = ParametricInstance::from(source); + /// + /// assert!(instance.to_v1_bytes().is_err()); + /// ``` pub fn to_v1_bytes(&self) -> Result> { let v1_instance = v1::ParametricInstance::try_from(self.clone())?; Ok(v1_instance.encode_to_vec()) } + /// Serialize this parametric instance using the v2 wire format. + /// + /// # Postconditions + /// + /// v2 serialization round-trips the parametric output objective. + /// + /// ``` + /// use ommx::{linear, DecisionVariable, Function, Instance, ParametricInstance, Sense, VariableID}; + /// use std::collections::BTreeMap; + /// + /// let mut source = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([( + /// VariableID::from(1), + /// DecisionVariable::binary(), + /// )])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(source.convert_active_objective(Sense::Minimize)); + /// let instance = ParametricInstance::from(source); + /// + /// let restored = ParametricInstance::from_v2_bytes(&instance.to_v2_bytes()).unwrap(); + /// assert_eq!(restored.output_objective().unwrap().sense(), Sense::Maximize); + /// assert_eq!(restored, instance); + /// ``` pub fn to_v2_bytes(&self) -> Vec { let v2_instance = v2::ParametricInstance::from(self.clone()); v2_instance.encode_to_vec() @@ -50,15 +153,19 @@ impl ParametricInstance { impl From for v2::Instance { fn from(value: Instance) -> Self { - let required_features = crate::v2_io::required_features( + let mut required_features = crate::v2_io::required_features( created_collection_has_payload(&value.indicator_constraint_collection), created_collection_has_payload(&value.one_hot_constraint_collection), created_collection_has_payload(&value.sos1_constraint_collection), ); + if value.output_objective.is_some() { + required_features.push(v2::Feature::OutputObjective as i32); + } let Instance { sense, objective, + output_objective, decision_variables, constraint_collection, indicator_constraint_collection, @@ -87,21 +194,36 @@ impl From for v2::Instance { ), named_functions: Some(named_functions.into()), annotations: crate::v2_io::extension_annotations_to_v2_map(annotations), + output_objective: output_objective.map(Into::into), + } + } +} + +impl From for v2::OutputObjective { + fn from(value: OutputObjective) -> Self { + Self { + sense: value.sense.into(), + function: Some(value.function.into()), + preserves_optimality: value.preserves_optimality, } } } impl From for v2::ParametricInstance { fn from(value: ParametricInstance) -> Self { - let required_features = crate::v2_io::required_features( + let mut required_features = crate::v2_io::required_features( created_collection_has_payload(&value.indicator_constraint_collection), created_collection_has_payload(&value.one_hot_constraint_collection), created_collection_has_payload(&value.sos1_constraint_collection), ); + if value.output_objective.is_some() { + required_features.push(v2::Feature::OutputObjective as i32); + } let ParametricInstance { sense, objective, + output_objective, decision_variables, parameters, constraint_collection, @@ -130,6 +252,7 @@ impl From for v2::ParametricInstance { ), named_functions: Some(named_functions.into()), annotations: crate::v2_io::extension_annotations_to_v2_map(annotations), + output_objective: output_objective.map(Into::into), } } } @@ -151,10 +274,11 @@ fn decision_variable_dependency_to_v2_map( mod tests { use super::*; use crate::{ - v2, ATol, DecisionVariable, Equality, Evaluate, Function, IndicatorConstraint, + linear, v2, ATol, DecisionVariable, Equality, Evaluate, Function, IndicatorConstraint, IndicatorConstraintID, OneHotConstraint, OneHotConstraintID, ParameterLabelStore, ParameterTable, Sampled, Sos1Constraint, Sos1ConstraintID, VariableID, }; + use proptest::prelude::*; use std::collections::{BTreeMap, BTreeSet, HashMap}; fn instance_with_special_constraints() -> Instance { @@ -211,6 +335,44 @@ mod tests { ] } + fn instance_with_output_objective() -> Instance { + let mut instance = Instance::builder() + .sense(Sense::Maximize) + .objective(Function::from(linear!(1))) + .decision_variables(BTreeMap::from([ + (VariableID::from(1), DecisionVariable::binary()), + (VariableID::from(2), DecisionVariable::binary()), + ])) + .constraints(BTreeMap::new()) + .build() + .unwrap(); + assert!(instance.convert_active_objective(Sense::Minimize)); + instance + } + + fn parametric_instance_with_output_objective() -> ParametricInstance { + let parameter_id = VariableID::from(100); + let output_function = + Function::from((linear!(1) + linear!(parameter_id.into_inner())).unwrap()); + let mut instance = ParametricInstance::builder() + .sense(Sense::Minimize) + .objective(output_function.clone()) + .decision_variables(BTreeMap::from([( + VariableID::from(1), + DecisionVariable::binary(), + )])) + .parameters(ParameterTable::from_ids(BTreeSet::from([parameter_id]))) + .constraints(BTreeMap::new()) + .build() + .unwrap(); + instance.output_objective = Some(OutputObjective::new( + Sense::Maximize, + output_function, + false, + )); + instance + } + fn assert_btree_map(_: &BTreeMap) {} #[test] @@ -291,6 +453,182 @@ mod tests { ); } + #[test] + fn v2_instance_parse_canonicalizes_redundant_output_objective() { + let instance = Instance::default(); + let mut proto = v2::Instance::from(instance.clone()); + proto + .required_features + .push(v2::Feature::OutputObjective as i32); + proto.output_objective = Some(v2::OutputObjective { + sense: proto.sense, + function: proto.objective.clone(), + preserves_optimality: true, + }); + + let restored = Instance::try_from(proto).unwrap(); + + assert_eq!(restored, instance); + assert!(restored.output_objective().is_none()); + assert!(restored.to_v1_bytes().is_ok()); + } + + #[test] + fn v2_instance_output_objective_requires_feature() { + let mut proto = v2::Instance::from(instance_with_output_objective()); + proto.required_features.clear(); + + let err = Instance::try_from(proto).unwrap_err(); + + assert!( + err.to_string().contains("required_features") + && err.to_string().contains("OutputObjective"), + "unexpected error: {err}", + ); + } + + #[test] + fn v2_instance_output_objective_feature_requires_payload() { + let mut proto = v2::Instance::from(instance_with_output_objective()); + proto.output_objective = None; + + let err = Instance::try_from(proto).unwrap_err(); + + assert!( + err.to_string().contains("output_objective") + && err.to_string().contains("OutputObjective"), + "unexpected error: {err}", + ); + } + + #[test] + fn v2_instance_rejects_undefined_output_objective_variable() { + let mut proto = v2::Instance::from(instance_with_output_objective()); + proto.output_objective.as_mut().unwrap().function = + Some(Function::from(linear!(999)).into()); + + let err = Instance::try_from(proto).unwrap_err(); + + assert!( + err.to_string().contains("output_objective.function") + && err.to_string().contains("Undefined variable ID"), + "unexpected error: {err}", + ); + } + + #[test] + fn v2_parametric_instance_round_trip_preserves_parameterized_output_objective() { + let instance = parametric_instance_with_output_objective(); + let proto = v2::ParametricInstance::from(instance.clone()); + + assert!(proto + .required_features + .contains(&(v2::Feature::OutputObjective as i32))); + let output = proto.output_objective.as_ref().unwrap(); + assert_eq!( + output.sense, + i32::from(crate::v1::instance::Sense::Maximize) + ); + assert!(!output.preserves_optimality); + let output_function: Function = output + .function + .as_ref() + .unwrap() + .clone() + .parse(&()) + .unwrap(); + assert!(output_function + .required_ids() + .contains(&VariableID::from(100))); + + let restored = ParametricInstance::try_from(proto).unwrap(); + assert_eq!(restored, instance); + } + + #[test] + fn v2_parametric_parse_canonicalizes_redundant_output_objective() { + let instance = ParametricInstance::default(); + let mut proto = v2::ParametricInstance::from(instance.clone()); + proto + .required_features + .push(v2::Feature::OutputObjective as i32); + proto.output_objective = Some(v2::OutputObjective { + sense: proto.sense, + function: proto.objective.clone(), + preserves_optimality: true, + }); + + let restored = ParametricInstance::try_from(proto).unwrap(); + + assert_eq!(restored, instance); + assert!(restored.output_objective().is_none()); + assert!(restored.to_v1_bytes().is_ok()); + } + + #[test] + fn v2_parametric_instance_output_objective_requires_feature() { + let mut proto = v2::ParametricInstance::from(parametric_instance_with_output_objective()); + proto.required_features.clear(); + + let err = ParametricInstance::try_from(proto).unwrap_err(); + assert!( + err.to_string().contains("required_features") + && err.to_string().contains("OutputObjective"), + "unexpected error: {err}", + ); + } + + #[test] + fn v2_parametric_instance_output_objective_feature_requires_payload() { + let mut proto = v2::ParametricInstance::from(parametric_instance_with_output_objective()); + proto.output_objective = None; + + let err = ParametricInstance::try_from(proto).unwrap_err(); + assert!( + err.to_string().contains("output_objective") + && err.to_string().contains("OutputObjective"), + "unexpected error: {err}", + ); + } + + #[test] + fn v2_parametric_instance_rejects_undefined_output_objective_id() { + let mut proto = v2::ParametricInstance::from(parametric_instance_with_output_objective()); + proto.output_objective.as_mut().unwrap().function = + Some(Function::from(linear!(999)).into()); + + let err = ParametricInstance::try_from(proto).unwrap_err(); + assert!( + err.to_string().contains("output_objective.function") + && err.to_string().contains("Undefined variable ID"), + "unexpected error: {err}", + ); + } + + #[test] + fn v2_solution_and_sample_set_reject_output_objective_feature() { + let instance = Instance::default(); + let state = crate::v1::State::default(); + + let mut solution = v2::Solution::from(instance.evaluate(&state, ATol::default()).unwrap()); + solution + .required_features + .push(v2::Feature::OutputObjective as i32); + let solution_error = crate::Solution::try_from(solution).unwrap_err(); + assert!(solution_error.to_string().contains("OutputObjective")); + + let mut sample_set = v2::SampleSet::from( + instance + .evaluate_samples(&Sampled::from(state), ATol::default()) + .unwrap(), + ); + sample_set + .required_features + .push(v2::Feature::OutputObjective as i32); + let sample_set_error = crate::SampleSet::try_from(sample_set).unwrap_err(); + assert!(sample_set_error.to_string().contains("OutputObjective")); + } + #[test] fn v2_instance_deserialization_rejects_missing_required_feature() { let mut proto = v2::Instance::from(instance_with_special_constraints()); @@ -561,4 +899,14 @@ mod tests { assert_eq!(restored, instance); assert_eq!(restored.parameters().labels().name(parameter_id), Some("p")); } + + proptest! { + #[test] + fn v2_instance_round_trip_preserves_full_v3_semantics( + instance in Instance::arbitrary_with(crate::InstanceParameters::full_v3()) + ) { + let restored = Instance::from_v2_bytes(&instance.to_v2_bytes()).unwrap(); + prop_assert_eq!(restored, instance); + } + } } diff --git a/rust/ommx/src/instance/setter.rs b/rust/ommx/src/instance/setter.rs index b7a6b2c04..fe8c92dd0 100644 --- a/rust/ommx/src/instance/setter.rs +++ b/rust/ommx/src/instance/setter.rs @@ -75,11 +75,44 @@ impl Instance { ) } - /// Set the objective function + /// Set the objective function and rebase output semantics. + /// + /// # Postconditions + /// + /// Setting the objective makes it the new active and output semantics. + /// + /// ``` + /// use ommx::{ + /// coeff, linear, v1::State, ATol, DecisionVariable, Evaluate, Function, + /// Instance, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let variable = VariableID::from(1); + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// + /// instance + /// .set_objective(Function::from((coeff!(2.0) * linear!(1)).unwrap())) + /// .unwrap(); + /// assert_eq!(instance.sense(), Sense::Minimize); + /// assert!(instance.output_objective().is_none()); + /// let state = State::from(HashMap::from([(1, 1.0)])); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Minimize)); + /// assert_eq!(*solution.objective(), 2.0); + /// ``` pub fn set_objective(&mut self, objective: Function) -> crate::Result<()> { // Validate that all variables in the objective are defined self.validate_required_ids(objective.required_ids())?; self.objective = objective; + self.output_objective = None; Ok(()) } diff --git a/rust/ommx/src/instance/substitute.rs b/rust/ommx/src/instance/substitute.rs index f4d2e0d13..785fefe95 100644 --- a/rust/ommx/src/instance/substitute.rs +++ b/rust/ommx/src/instance/substitute.rs @@ -184,6 +184,40 @@ impl Instance { } } +/// # Postconditions +/// +/// Substitution rewrites active functions while preserving the output objective. +/// +/// ``` +/// use ommx::{ +/// linear, substitute, v1::State, ATol, DecisionVariable, Evaluate, Function, +/// Instance, Sense, VariableID, +/// }; +/// use std::collections::{BTreeMap, HashMap}; +/// +/// let original = VariableID::from(0); +/// let replacement = VariableID::from(1); +/// let mut instance = Instance::builder() +/// .sense(Sense::Maximize) +/// .objective(Function::from(linear!(0))) +/// .decision_variables(BTreeMap::from([ +/// (original, DecisionVariable::binary()), +/// (replacement, DecisionVariable::binary()), +/// ])) +/// .constraints(BTreeMap::new()) +/// .build() +/// .unwrap(); +/// assert!(instance.convert_active_objective(Sense::Minimize)); +/// let output = instance.output_objective().cloned(); +/// +/// substitute(&mut instance, [(original, Function::from(linear!(1)))]).unwrap(); +/// assert_eq!(instance.output_objective(), output.as_ref()); +/// let state = State::from(HashMap::from([(1, 1.0)])); +/// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -1.0); +/// let solution = instance.evaluate(&state, ATol::default()).unwrap(); +/// assert_eq!(*solution.sense(), Some(Sense::Maximize)); +/// assert_eq!(*solution.objective(), 1.0); +/// ``` impl Substitute for Instance { type Output = Self; @@ -443,6 +477,46 @@ mod tests { .evaluate(&state, crate::ATol::default()) .unwrap(); assert_eq!(value, 7.0); + let solution = instance.evaluate(&state, crate::ATol::default()).unwrap(); + assert_eq!(*solution.objective(), 7.0); + } + + #[test] + fn parametric_substitution_preserves_existing_output_objective() { + let decision_variables = BTreeMap::from([ + (VariableID::from(0), DecisionVariable::continuous()), + (VariableID::from(1), DecisionVariable::continuous()), + ]); + let constraint = Constraint::equal_to_zero(Function::from(linear!(0) + coeff!(-1.0))); + let mut instance = Instance::new( + Sense::Minimize, + Function::from(linear!(0)), + decision_variables, + BTreeMap::from([(ConstraintID::from(0), constraint)]), + ) + .unwrap(); + assert!(instance.convert_active_objective(Sense::Maximize)); + let parametric = instance.penalty_method().unwrap(); + let output = parametric.output_objective().cloned().unwrap(); + + let substituted = parametric + .substitute_one(VariableID::from(0), &Function::from(linear!(1))) + .unwrap(); + + assert_eq!(substituted.output_objective(), Some(&output)); + let parameters = crate::v1::Parameters { + entries: substituted + .parameters() + .keys() + .map(|id| (id.into_inner(), 2.0)) + .collect(), + }; + let materialized = substituted.with_parameters(parameters).unwrap(); + assert_eq!(materialized.output_objective(), Some(&output)); + let solution = materialized + .evaluate(&crate::v1::State::from_iter([(1, 1.0)]), ATol::default()) + .unwrap(); + assert_eq!(*solution.objective(), 1.0); } #[test] diff --git a/rust/ommx/src/instance/unary_encode.rs b/rust/ommx/src/instance/unary_encode.rs index a15490cdf..34edde33c 100644 --- a/rust/ommx/src/instance/unary_encode.rs +++ b/rust/ommx/src/instance/unary_encode.rs @@ -70,6 +70,50 @@ impl Instance { /// clone, and the result is committed back only if all encodings succeed. /// Duplicate IDs are encoded once. Pass a single-element iterator such as /// `[id]` to encode exactly one variable. + /// + /// # Postconditions + /// + /// Encoding rewrites only the active formulation and preserves evaluation + /// in the pre-encoding output semantics. + /// + /// ``` + /// use ommx::{ + /// linear, v1::State, ATol, Bound, DecisionVariable, Evaluate, Function, + /// Instance, Kind, Sense, VariableID, + /// }; + /// use std::collections::{BTreeMap, HashMap}; + /// + /// let variable = VariableID::from(0); + /// let integer = DecisionVariable::new( + /// Kind::Integer, + /// Bound::new(2.0, 5.0).unwrap(), + /// ATol::default(), + /// ) + /// .unwrap(); + /// let mut instance = Instance::builder() + /// .sense(Sense::Maximize) + /// .objective(Function::from(linear!(0))) + /// .decision_variables(BTreeMap::from([(variable, integer)])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// assert!(instance.convert_active_objective(Sense::Minimize)); + /// let output = instance.output_objective().cloned(); + /// + /// instance + /// .unary_encode([variable], 3, ATol::default()) + /// .unwrap(); + /// assert_eq!(instance.output_objective(), output.as_ref()); + /// let encoded_ids = instance.required_ids(); + /// assert_eq!(encoded_ids.len(), 3); + /// let state = State::from(HashMap::from_iter( + /// encoded_ids.into_iter().map(|id| (id.into_inner(), 1.0)), + /// )); + /// assert_eq!(instance.objective().evaluate(&state, ATol::default()).unwrap(), -5.0); + /// let solution = instance.evaluate(&state, ATol::default()).unwrap(); + /// assert_eq!(*solution.sense(), Some(Sense::Maximize)); + /// assert_eq!(*solution.objective(), 5.0); + /// ``` #[tracing::instrument(skip(self, ids))] pub fn unary_encode( &mut self, @@ -165,8 +209,8 @@ impl Instance { mod tests { use super::*; use crate::{ - coeff, v1::State, Bound, DecisionVariable, Equality, Evaluate, Function, - IndicatorConstraint, IndicatorConstraintID, Instance, Kind, LinearMonomial, + coeff, v1::State, Bound, DecisionVariable, DecisionVariableRole, Equality, Evaluate, + Function, IndicatorConstraint, IndicatorConstraintID, Instance, Kind, LinearMonomial, OneHotConstraint, OneHotConstraintID, Sense, Solution, Sos1Constraint, Sos1ConstraintID, }; use approx::relative_eq; @@ -594,6 +638,63 @@ mod tests { } } + #[test] + fn unary_encode_does_not_create_or_rewrite_output_objective() { + let id = VariableID::from(0); + let make_instance = || { + let variable = DecisionVariable::new( + Kind::Integer, + Bound::new(0.0, 3.0).unwrap(), + ATol::default(), + ) + .unwrap(); + Instance::builder() + .sense(Sense::Maximize) + .objective(Function::from(crate::linear!(0))) + .decision_variables(BTreeMap::from([(id, variable)])) + .constraints(BTreeMap::new()) + .build() + .unwrap() + }; + + let mut without_output = make_instance(); + without_output + .unary_encode( + [id], + Instance::DEFAULT_UNARY_ENCODING_MAX_RANGE, + ATol::default(), + ) + .unwrap(); + assert!(without_output.output_objective().is_none()); + + let mut with_output = make_instance(); + assert!(with_output.convert_active_objective(Sense::Minimize)); + let output = with_output.output_objective().cloned().unwrap(); + with_output + .unary_encode( + [id], + Instance::DEFAULT_UNARY_ENCODING_MAX_RANGE, + ATol::default(), + ) + .unwrap(); + + assert_eq!(with_output.output_objective(), Some(&output)); + assert_eq!( + with_output.decision_variable_role(id), + Some(DecisionVariableRole::Dependent) + ); + assert!(!with_output.used_decision_variable_ids().contains(&id)); + let state = State::from_iter( + with_output + .used_decision_variable_ids() + .into_iter() + .map(|id| (id.into_inner(), 0.0)), + ); + let solution = with_output.evaluate(&state, ATol::default()).unwrap(); + assert_eq!(*solution.sense(), Some(Sense::Maximize)); + assert_eq!(*solution.objective(), 0.0); + } + #[test] fn test_unary_encoding_size() { let bound = Bound::new(0.0, 3.0).unwrap(); diff --git a/rust/ommx/src/instance_class.rs b/rust/ommx/src/instance_class.rs index 62a2a515d..be79dfbc6 100644 --- a/rust/ommx/src/instance_class.rs +++ b/rust/ommx/src/instance_class.rs @@ -382,6 +382,141 @@ impl InstanceClass { Self { clauses } } + /// Return the class of QUBO solver inputs. + /// + /// # Postconditions + /// + /// The returned class admits only unconstrained minimization models over + /// Binary variables whose objective degree is at most two. + /// + /// ``` + /// use ommx::{ + /// linear, monomial, Constraint, ConstraintID, DecisionVariable, Function, + /// Instance, InstanceClass, Sense, VariableID, + /// }; + /// use std::collections::BTreeMap; + /// + /// let variable = VariableID::from(1); + /// let binary = BTreeMap::from([(variable, DecisionVariable::binary())]); + /// let build = |sense, objective, constraints| { + /// Instance::builder() + /// .sense(sense) + /// .objective(objective) + /// .decision_variables(binary.clone()) + /// .constraints(constraints) + /// .build() + /// .unwrap() + /// }; + /// let linear = build(Sense::Minimize, Function::from(linear!(1)), BTreeMap::new()); + /// let quadratic = build( + /// Sense::Minimize, + /// Function::from(monomial!(1, 1)), + /// BTreeMap::new(), + /// ); + /// let cubic = build( + /// Sense::Minimize, + /// Function::from(monomial!(1, 1, 1)), + /// BTreeMap::new(), + /// ); + /// let maximizing = build(Sense::Maximize, Function::from(linear!(1)), BTreeMap::new()); + /// let constrained = build( + /// Sense::Minimize, + /// Function::from(linear!(1)), + /// BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(Function::from(linear!(1))), + /// )]), + /// ); + /// let non_binary = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::continuous())])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap(); + /// + /// let class = InstanceClass::qubo(); + /// assert!(class.contains(&linear)); + /// assert!(class.contains(&quadratic)); + /// assert!(!class.contains(&cubic)); + /// assert!(!class.contains(&maximizing)); + /// assert!(!class.contains(&constrained)); + /// assert!(!class.contains(&non_binary)); + /// ``` + pub fn qubo() -> Self { + InstanceClassClause::new( + "qubo", + BTreeSet::from([Kind::Binary]), + DegreeBound::at_most(2), + BTreeSet::from([Sense::Minimize]), + ) + .into() + } + + /// Return the class of Binary HUBO solver inputs. + /// + /// # Postconditions + /// + /// The returned class admits unconstrained minimization models over Binary + /// variables with any polynomial objective degree. + /// + /// ``` + /// use ommx::{ + /// linear, monomial, Constraint, ConstraintID, DecisionVariable, Function, + /// Instance, InstanceClass, Sense, VariableID, + /// }; + /// use std::collections::BTreeMap; + /// + /// let variable = VariableID::from(1); + /// let build = |sense, objective, decision_variable| { + /// Instance::builder() + /// .sense(sense) + /// .objective(objective) + /// .decision_variables(BTreeMap::from([(variable, decision_variable)])) + /// .constraints(BTreeMap::new()) + /// .build() + /// .unwrap() + /// }; + /// let linear = build(Sense::Minimize, Function::from(linear!(1)), DecisionVariable::binary()); + /// let cubic = build( + /// Sense::Minimize, + /// Function::from(monomial!(1, 1, 1)), + /// DecisionVariable::binary(), + /// ); + /// let maximizing = build(Sense::Maximize, Function::from(linear!(1)), DecisionVariable::binary()); + /// let non_binary = build( + /// Sense::Minimize, + /// Function::from(linear!(1)), + /// DecisionVariable::continuous(), + /// ); + /// let constrained = Instance::builder() + /// .sense(Sense::Minimize) + /// .objective(Function::from(linear!(1))) + /// .decision_variables(BTreeMap::from([(variable, DecisionVariable::binary())])) + /// .constraints(BTreeMap::from([( + /// ConstraintID::from(1), + /// Constraint::equal_to_zero(Function::from(linear!(1))), + /// )])) + /// .build() + /// .unwrap(); + /// + /// let class = InstanceClass::hubo(); + /// assert!(class.contains(&linear)); + /// assert!(class.contains(&cubic)); + /// assert!(!class.contains(&maximizing)); + /// assert!(!class.contains(&non_binary)); + /// assert!(!class.contains(&constrained)); + /// ``` + pub fn hubo() -> Self { + InstanceClassClause::new( + "hubo", + BTreeSet::from([Kind::Binary]), + DegreeBound::Unbounded, + BTreeSet::from([Sense::Minimize]), + ) + .into() + } + /// Return the clauses representing this class. pub fn clauses(&self) -> &[InstanceClassClause] { &self.clauses diff --git a/rust/ommx/src/ommx.v2.rs b/rust/ommx/src/ommx.v2.rs index 0a331306f..0a11ad00e 100644 --- a/rust/ommx/src/ommx.v2.rs +++ b/rust/ommx/src/ommx.v2.rs @@ -45,6 +45,9 @@ pub enum Feature { ConstraintOneHot = 2, /// The payload contains first-class SOS1 constraints. ConstraintSos1 = 3, + /// The Instance or ParametricInstance payload explicitly carries + /// output-objective semantics separately from the active solver formulation. + OutputObjective = 4, } impl Feature { /// String value of the enum field names used in the ProtoBuf definition. @@ -57,6 +60,7 @@ impl Feature { Feature::ConstraintIndicator => "FEATURE_CONSTRAINT_INDICATOR", Feature::ConstraintOneHot => "FEATURE_CONSTRAINT_ONE_HOT", Feature::ConstraintSos1 => "FEATURE_CONSTRAINT_SOS1", + Feature::OutputObjective => "FEATURE_OUTPUT_OBJECTIVE", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -66,6 +70,7 @@ impl Feature { "FEATURE_CONSTRAINT_INDICATOR" => Some(Self::ConstraintIndicator), "FEATURE_CONSTRAINT_ONE_HOT" => Some(Self::ConstraintOneHot), "FEATURE_CONSTRAINT_SOS1" => Some(Self::ConstraintSos1), + "FEATURE_OUTPUT_OBJECTIVE" => Some(Self::OutputObjective), _ => None, } } @@ -578,6 +583,33 @@ pub struct SampledNamedFunctionTable { #[prost(btree_map = "uint64, message", tag = "2")] pub labels: ::prost::alloc::collections::BTreeMap, } +/// Serialized output-objective semantics distinct from the root's active objective. +/// +/// `sense` and `function` form one atomic pair. A validated payload carries +/// both. When a root omits `output_objective`, its own `sense` and `objective` +/// are also its output semantics. The pair may equal the active pair when this +/// payload exists only to record that active-formulation optimality does not +/// transport. +#[non_exhaustive] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OutputObjective { + #[prost(enumeration = "super::v1::instance::Sense", tag = "1")] + pub sense: i32, + #[prost(message, optional, tag = "2")] + pub function: ::core::option::Option, + /// Whether optimality for the active formulation also proves optimality for + /// this output objective. + /// + /// This compares objective orderings over candidate states of the active + /// formulation. It does not assert feasibility or optimality with respect to + /// removed constraints. + /// + /// `false` is conservative: it means that such a proof is not available, + /// not that the reconstructed state is known to be suboptimal. + #[prost(bool, tag = "3")] + pub preserves_optimality: bool, +} /// Validated optimization problem serialization root. #[non_exhaustive] #[allow(clippy::derive_partial_eq_without_eq)] @@ -613,6 +645,10 @@ pub struct Instance { ::prost::alloc::string::String, ::prost::alloc::string::String, >, + /// Optional output-objective pair. Function references must resolve to + /// decision variables owned by this root. + #[prost(message, optional, tag = "14")] + pub output_objective: ::core::option::Option, } /// Parameter IDs and labels owned by ParametricInstance. /// @@ -665,6 +701,10 @@ pub struct ParametricInstance { ::prost::alloc::string::String, ::prost::alloc::string::String, >, + /// Optional output-objective pair. Function references must resolve to + /// decision variables or parameters owned by this root. + #[prost(message, optional, tag = "14")] + pub output_objective: ::core::option::Option, } /// Validated multi-sample solver or sampler output serialization root. #[non_exhaustive] diff --git a/rust/ommx/src/sample_set/parse.rs b/rust/ommx/src/sample_set/parse.rs index f50350c81..f4d54982f 100644 --- a/rust/ommx/src/sample_set/parse.rs +++ b/rust/ommx/src/sample_set/parse.rs @@ -262,6 +262,7 @@ impl Parse for v2::SampleSet { let message = "ommx.v2.SampleSet"; let required_features = crate::v2_io::parse_required_features(self.required_features, message)?; + crate::v2_io::reject_output_objective_feature(&required_features, message)?; let feasibility_atol = crate::v2_io::parse_feasibility_atol(self.feasibility_atol, message)?; let annotations = diff --git a/rust/ommx/src/solution/parse.rs b/rust/ommx/src/solution/parse.rs index c619d18f7..9bab5f6d4 100644 --- a/rust/ommx/src/solution/parse.rs +++ b/rust/ommx/src/solution/parse.rs @@ -324,6 +324,7 @@ impl Parse for v2::Solution { let message = "ommx.v2.Solution"; let required_features = crate::v2_io::parse_required_features(self.required_features, message)?; + crate::v2_io::reject_output_objective_feature(&required_features, message)?; let feasibility_atol = crate::v2_io::parse_feasibility_atol(self.feasibility_atol, message)?; let annotations = diff --git a/rust/ommx/src/v2_io.rs b/rust/ommx/src/v2_io.rs index 771fd327b..2a7624d16 100644 --- a/rust/ommx/src/v2_io.rs +++ b/rust/ommx/src/v2_io.rs @@ -98,6 +98,23 @@ pub fn validate_feature_payload( } } +/// Reject the output-objective feature on v2 roots that cannot carry its payload. +/// +/// `Feature` is a protobuf-global enum, so merely recognizing its numeric value +/// is not enough to establish that a particular root can represent it. +pub fn reject_output_objective_feature( + required_features: &BTreeSet, + message: &'static str, +) -> Result<(), ParseError> { + validate_feature_payload( + required_features, + Feature::OutputObjective, + false, + message, + "output_objective", + ) +} + pub fn parse_feasibility_atol( value: Option, message: &'static str,