From cdde1fb4bb1e68c5a17b4a6a2fb644e9727035c9 Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Wed, 24 Dec 2025 06:34:50 -0800 Subject: [PATCH 1/4] feat: infer input dimensions --- pyproject.toml | 2 +- src/opvious/data/solves.py | 73 +++++++++++++++++++++-------- src/opvious/data/tensors.py | 8 ++-- src/opvious/modeling/definitions.py | 3 ++ src/opvious/modeling/fragments.py | 4 ++ 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51e87e9..8ca2a2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires = ['poetry-core'] build-backend = 'poetry.core.masonry.api' [tool.poetry] -version = '0.23.5rc2' +version = '0.24.0rc1' packages = [{include = 'opvious', from = 'src'}] [tool.poetry.dependencies] diff --git a/src/opvious/data/solves.py b/src/opvious/data/solves.py index a96a296..b8656be 100644 --- a/src/opvious/data/solves.py +++ b/src/opvious/data/solves.py @@ -19,6 +19,7 @@ UnboundedOutcome, ) from .outlines import Label, ObjectiveSense, ProblemOutline, SourceBinding +from .tensors import KeyItem @dataclasses.dataclass(frozen=True) @@ -177,14 +178,19 @@ class SolveInputs: problem_outline: ProblemOutline """Target model metadata""" - raw_parameters: list[Json] = dataclasses.field(repr=False) + raw_parameters: Sequence[Json] = dataclasses.field(repr=False) """All parameters in raw format""" - raw_dimensions: list[Json] | None = dataclasses.field(repr=False) + raw_dimensions: Sequence[Json] | None = dataclasses.field(repr=False) """All dimensions in raw format""" - def parameter(self, label: Label, coerce: bool = True) -> pd.DataFrame: - """Returns the parameter for a given label as a pandas DataFrame + def parameter( + self, + label: Label, + coerce: bool = True, + index: pd.Index | Sequence[KeyItem] | None = None, + ) -> pd.DataFrame: + """Returns the parameter for a given label as a pandas dataframe The returned dataframe has a `value` column with the parameter's values (0 values may be omitted). @@ -192,22 +198,38 @@ def parameter(self, label: Label, coerce: bool = True) -> pd.DataFrame: Args: label: Parameter label to retrieve coerce: Round integral parameters + index: Returned dataframe index """ for param in self.raw_parameters: if param["label"] == label: outline = self.problem_outline.parameters[label] - return _entries_dataframe( + return _tensor_dataframe( param["entries"], outline.bindings, + index=index, round_values=coerce and outline.is_integral, ) raise Exception(f"Unknown parameter: {label}") def dimension(self, label: Label) -> pd.Index: """Returns the dimension for a given label as a pandas Index""" - for dim in self.raw_dimensions or []: - if dim["label"] == label: - return pd.Index(dim["items"]) + if self.raw_dimensions is not None: + for dim in self.raw_dimensions: + if dim["label"] == label: + return pd.Index(dim["items"]) + else: + items = set() + has_binding = False + for param in self.raw_parameters: + outline = self.problem_outline.parameters[param["label"]] + for i, binding in enumerate(outline.bindings): + if binding.dimension_label != label: + continue + has_binding = True + for entry in param["entries"]: + items.add(entry["key"][i]) + if has_binding: + return pd.Index(items).sort_values() raise Exception(f"Unknown dimension: {label}") @@ -218,13 +240,18 @@ class SolveOutputs: problem_outline: ProblemOutline """Solved model metadata""" - raw_variables: list[Json] = dataclasses.field(repr=False) + raw_variables: Sequence[Json] = dataclasses.field(repr=False) """All variables in raw format""" - raw_constraints: list[Json] = dataclasses.field(repr=False) + raw_constraints: Sequence[Json] = dataclasses.field(repr=False) """All constraints in raw format""" - def variable(self, label: Label, coerce: bool = True) -> pd.DataFrame: + def variable( + self, + label: Label, + coerce: bool = True, + index: pd.Index | Sequence[KeyItem] | None = None, + ) -> pd.DataFrame: """Returns variable results for a given label The returned dataframe always has a `value` column with the variable's @@ -234,14 +261,16 @@ def variable(self, label: Label, coerce: bool = True) -> pd.DataFrame: Args: label: Variable label to retrieve coerce: Round integral variables + index: Returned dataframe index """ for res in self.raw_variables: if res["label"] == label: outline = self.problem_outline.variables[label] - return _entries_dataframe( + return _tensor_dataframe( res["entries"], outline.bindings, dual_value_name="reduced_cost", + index=index, round_values=coerce and outline.is_integral, ) raise Exception(f"Unknown variable {label}") @@ -255,7 +284,7 @@ def constraint(self, label: Label) -> pd.DataFrame: """ for res in self.raw_constraints: if res["label"] == label: - return _entries_dataframe( + return _tensor_dataframe( res["entries"], self.problem_outline.constraints[label].bindings, value_name="slack", @@ -264,20 +293,26 @@ def constraint(self, label: Label) -> pd.DataFrame: raise Exception(f"Unknown constraint {label}") -def _entries_dataframe( - entries: Sequence[Json], +def _tensor_dataframe( + tensor_json: Json, bindings: Sequence[SourceBinding], *, value_name: str = "value", dual_value_name: str | None = None, + index: pd.Index | Sequence[KeyItem] | None = None, round_values: bool = False, ) -> pd.DataFrame: + entries = tensor_json["entries"] + default_values = { + value_name: decode_extended_float(tensor_json["default_value"]), + } if dual_value_name: data = ( (decode_extended_float(e["value"]), e.get("dualValue")) for e in entries ) columns = [value_name, dual_value_name] + default_values[dual_value_name] = 0 else: data = (decode_extended_float(e["value"]) for e in entries) columns = [value_name] @@ -287,11 +322,11 @@ def _entries_dataframe( index=_entry_index(entries, bindings), ) if dual_value_name and df[dual_value_name].isnull().all(): - df.drop(dual_value_name, axis=1, inplace=True) + df = df.drop(dual_value_name, axis=1) + df = df.sort_index() if index is None else df.reindex(cast(Any, index)) + df = df.fillna(default_values) if round_values: df[value_name] = df[value_name].round(0).astype(np.int64) - df.fillna(0, inplace=True) - df.sort_index(inplace=True) return df @@ -463,7 +498,7 @@ class SolveStrategy: sense: ObjectiveSense | None = None """Optimization sense""" - epsilon_constraints: list[EpsilonConstraint] = dataclasses.field( + epsilon_constraints: Sequence[EpsilonConstraint] = dataclasses.field( default_factory=lambda: [] ) """All epsilon-constraints to apply""" diff --git a/src/opvious/data/tensors.py b/src/opvious/data/tensors.py index b45ad7f..862479d 100644 --- a/src/opvious/data/tensors.py +++ b/src/opvious/data/tensors.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Sequence import dataclasses import math from typing import Any, Self @@ -40,12 +40,12 @@ def is_value(arg: Any) -> bool: ) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Tensor: """An n-dimensional matrix""" - entries: list[Any] - """Raw list of matrix entries""" + entries: Sequence[Any] + """Raw matrix entries""" default_value: ExtendedFloat = 0 """Value to use for missing key""" diff --git a/src/opvious/modeling/definitions.py b/src/opvious/modeling/definitions.py index 077b74d..d9ba1e1 100644 --- a/src/opvious/modeling/definitions.py +++ b/src/opvious/modeling/definitions.py @@ -214,6 +214,9 @@ def total_product_count(self): The number of arguments must match the tensor's quantification. """ + # TODO: Add map method, which appends to _mappers array of transformations. + # Once implemented, remove the negate arguments to transformations. + def __init__( self, image: Image, diff --git a/src/opvious/modeling/fragments.py b/src/opvious/modeling/fragments.py index c79de39..da784b2 100644 --- a/src/opvious/modeling/fragments.py +++ b/src/opvious/modeling/fragments.py @@ -625,6 +625,8 @@ def activated_variable( indicator_projection: Projection = -1, upper_bound: ExpressionLike | None = None, negate: bool = False, + force_activation: bool = True, + force_deactivation: bool = True, name: Name | None = None, ) -> Callable[[TensorLike], ActivatedVariable]: """Wraps a method into an :class:`ActivatedVariable` fragment @@ -640,6 +642,8 @@ def wrapper(fn: TensorLike) -> ActivatedVariable: indicator_projection=indicator_projection, upper_bound=upper_bound, negate=negate, + force_activation=force_activation, + force_deactivation=force_deactivation, name=name, ) From 20f8c8dd0caa96561b92b528d072f08a95571772 Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Wed, 24 Dec 2025 06:38:53 -0800 Subject: [PATCH 2/4] fixup! cdde1fb --- src/opvious/data/solves.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/opvious/data/solves.py b/src/opvious/data/solves.py index b8656be..74499c4 100644 --- a/src/opvious/data/solves.py +++ b/src/opvious/data/solves.py @@ -203,8 +203,8 @@ def parameter( for param in self.raw_parameters: if param["label"] == label: outline = self.problem_outline.parameters[label] - return _tensor_dataframe( - param["entries"], + return _tensor_json_dataframe( + param, outline.bindings, index=index, round_values=coerce and outline.is_integral, @@ -266,8 +266,8 @@ def variable( for res in self.raw_variables: if res["label"] == label: outline = self.problem_outline.variables[label] - return _tensor_dataframe( - res["entries"], + return _tensor_json_dataframe( + res, outline.bindings, dual_value_name="reduced_cost", index=index, @@ -284,8 +284,8 @@ def constraint(self, label: Label) -> pd.DataFrame: """ for res in self.raw_constraints: if res["label"] == label: - return _tensor_dataframe( - res["entries"], + return _tensor_json_dataframe( + res, self.problem_outline.constraints[label].bindings, value_name="slack", dual_value_name="shadow_price", @@ -293,7 +293,7 @@ def constraint(self, label: Label) -> pd.DataFrame: raise Exception(f"Unknown constraint {label}") -def _tensor_dataframe( +def _tensor_json_dataframe( tensor_json: Json, bindings: Sequence[SourceBinding], *, From 507e54e83e27192c5b0abe0f2d4b383d5b165c74 Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Wed, 24 Dec 2025 06:42:30 -0800 Subject: [PATCH 3/4] fixup! 20f8c8d --- src/opvious/data/solves.py | 2 +- tests/client_test.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/opvious/data/solves.py b/src/opvious/data/solves.py index 74499c4..c8c179e 100644 --- a/src/opvious/data/solves.py +++ b/src/opvious/data/solves.py @@ -304,7 +304,7 @@ def _tensor_json_dataframe( ) -> pd.DataFrame: entries = tensor_json["entries"] default_values = { - value_name: decode_extended_float(tensor_json["default_value"]), + value_name: decode_extended_float(tensor_json.get("default_value", 0)), } if dual_value_name: data = ( diff --git a/tests/client_test.py b/tests/client_test.py index b8e524d..e79a755 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -86,6 +86,8 @@ async def test_queue_diet_solve(self): "salad": 9, "caviar": 23, } + nutrients = input_data.dimension("nutrients") + assert list(nutrients) == ["carbs", "fibers", "vitamins"] output_data = await client.fetch_solve_outputs(uuid) quantities = output_data.variable("quantityOfRecipe") From fb3f33e831c60b1b87b5ef0d3a1a9fdcda55ca5b Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Wed, 24 Dec 2025 06:45:45 -0800 Subject: [PATCH 4/4] fixup! 507e54e --- src/opvious/data/solves.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/opvious/data/solves.py b/src/opvious/data/solves.py index c8c179e..1a003d6 100644 --- a/src/opvious/data/solves.py +++ b/src/opvious/data/solves.py @@ -216,7 +216,7 @@ def dimension(self, label: Label) -> pd.Index: if self.raw_dimensions is not None: for dim in self.raw_dimensions: if dim["label"] == label: - return pd.Index(dim["items"]) + return pd.Index(dim["items"], name=label) else: items = set() has_binding = False @@ -229,7 +229,7 @@ def dimension(self, label: Label) -> pd.Index: for entry in param["entries"]: items.add(entry["key"][i]) if has_binding: - return pd.Index(items).sort_values() + return pd.Index(items, name=label).sort_values() raise Exception(f"Unknown dimension: {label}") @@ -304,7 +304,7 @@ def _tensor_json_dataframe( ) -> pd.DataFrame: entries = tensor_json["entries"] default_values = { - value_name: decode_extended_float(tensor_json.get("default_value", 0)), + value_name: decode_extended_float(tensor_json.get("defaultValue", 0)), } if dual_value_name: data = (