diff --git a/CHANGELOG.md b/CHANGELOG.md index 7451fa7a9c..d3ddc3f066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 number of required models instead of the number of transform outputs ### Added -- `coefficients` attribute for `DiscreteSumConstraint`, enabling weighted sums. Follows - the same pattern as `ContinuousLinearConstraint.coefficients` - `simplex_coefficients` keyword argument to `SubspaceDiscrete.from_simplex` for weighted simplex sum constraints - `Symmetry` concept for expressing symmetries of the optimization problem, including @@ -43,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DiscreteRepetitionConstraint` for controlling value repetition across parameters via `n_max_repetitions` (replaces `DiscreteNoLabelDuplicatesConstraint` and `DiscreteLinkedParametersConstraint`) +- `DiscreteLinearConstraint` for (optionally weighted) sum constraints on discrete + parameters, supporting `coefficients` and mirroring `ContinuousLinearConstraint`'s + `operator`/`rhs`/`coefficients` interface (replaces `DiscreteSumConstraint`) ### Changed - `BOTORCH` GP preset now includes `BetaPrior(2.5, 1.5)` for the task covariance @@ -50,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in version `0.18.0` - The `BOTORCH` GP preset now requires BoTorch `>= 0.18.0` and raises an `IncompatibilityError` if an older version is installed -- `DiscreteSumConstraint`, `ContinuousLinearConstraint`, and +- `DiscreteLinearConstraint`, `ContinuousLinearConstraint`, and `SubspaceDiscrete.from_simplex` now forbid 0 as coefficients - `SubspaceDiscrete.from_simplex` no longer requires non-negative parameter values - Bumped polars to `>=0.20.8` @@ -62,6 +63,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Discrete filtering constraints now uniformly define what is **kept** in the search space; set `exclude=True` to invert and keep the complement instead - Renamed `exclusion_constraints` example to `selection_constraints` +- `DiscreteProductConstraint` now uses `operator`/`rhs`/`tolerance` instead of + `condition` ### Deprecations - `DiscreteExcludeConstraint` in favor of @@ -71,6 +74,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DiscreteLinkedParametersConstraint` in favor of `DiscreteRepetitionConstraint(..., n_max_repetitions=len(parameters)-1, exclude=True)` +- `DiscreteSumConstraint` in favor of `DiscreteLinearConstraint` +- `DiscreteProductConstraint(condition=ThresholdCondition(...))` in favor of + `DiscreteProductConstraint(operator=..., rhs=...)` ## [0.15.0] - 2026-06-11 ### Breaking Changes diff --git a/baybe/constraints/__init__.py b/baybe/constraints/__init__.py index 7c468b0c51..f25892485c 100644 --- a/baybe/constraints/__init__.py +++ b/baybe/constraints/__init__.py @@ -12,6 +12,7 @@ DiscreteCustomConstraint, DiscreteDependenciesConstraint, DiscreteExcludeConstraint, + DiscreteLinearConstraint, DiscreteLinkedParametersConstraint, DiscreteNoLabelDuplicatesConstraint, DiscretePermutationInvarianceConstraint, @@ -35,6 +36,7 @@ "DiscreteCustomConstraint", "DiscreteDependenciesConstraint", "DiscreteExcludeConstraint", + "DiscreteLinearConstraint", "DiscreteLinkedParametersConstraint", "DiscreteNoLabelDuplicatesConstraint", "DiscretePermutationInvarianceConstraint", diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index 75e9d26f3a..ec8d0b06e3 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -50,7 +50,9 @@ class ContinuousLinearConstraint(ContinuousConstraint): converter=lambda x: cattrs.structure(x, tuple[float, ...]), validator=deep_iterable(member_validator=finite_float), ) - """In-/equality coefficient for each entry in ``parameters``.""" + """The coefficients for the weighted sum, one per entry in ``parameters``. + + Defaults to all-ones, i.e. an unweighted sum.""" rhs: float = field(default=0.0, converter=float, validator=finite_float) """Right-hand side value of the in-/equality.""" diff --git a/baybe/constraints/discrete.py b/baybe/constraints/discrete.py index 464a573749..fba86fdd70 100644 --- a/baybe/constraints/discrete.py +++ b/baybe/constraints/discrete.py @@ -27,6 +27,7 @@ ThresholdCondition, _threshold_operators, _valid_logic_combiners, + _valid_tolerance_operators, ) from baybe.serialization import ( block_deserialization_hook, @@ -114,8 +115,8 @@ def _can_evaluate(self, available: set[str], /) -> bool: # - OR with exclude=True: once a present condition holds, the row is # permanently marked for removal (an OR match stays). # For XOR, the combined result can flip as further operands arrive, so - # all parameters must be present first. All other cases must likewise - # wait for every parameter. + # all parameters must be present before evaluating. All other cases must + # likewise wait for every parameter. present = available & set(self.parameters) if not present: return False @@ -146,11 +147,13 @@ def _get_matching_rows_polars(self, schema: pl.Schema) -> pl.Expr: @define -class DiscreteSumConstraint(DiscreteFilteringConstraint): - """Class for modelling sum constraints. +class DiscreteLinearConstraint(DiscreteFilteringConstraint): + """Class for modelling linear (weighted-sum) constraints on discrete parameters. - The constraint evaluates whether the (optionally weighted) sum of the specified - parameters satisfies the given threshold condition. + The constraint compares the sum of the specified parameters, optionally weighted by + :paramref:`DiscreteLinearConstraint.coefficients`, against + :paramref:`DiscreteLinearConstraint.rhs` using + :paramref:`DiscreteLinearConstraint.operator`. Examples: >>> df = pd.DataFrame({"A": [1.0, 3.0, 5.0], "B": [2.0, 1.0, 3.0]}) @@ -159,26 +162,26 @@ class DiscreteSumConstraint(DiscreteFilteringConstraint): 0 1.0 2.0 1 3.0 1.0 2 5.0 3.0 - >>> c = DiscreteSumConstraint( + >>> c = DiscreteLinearConstraint( ... parameters=["A", "B"], - ... condition=ThresholdCondition(threshold=5.0, operator="<="), + ... operator="<=", + ... rhs=5.0, ... ) >>> list(c.get_invalid(df)) [2] With coefficients, the weighted sum is checked instead: - >>> c = DiscreteSumConstraint( + >>> c = DiscreteLinearConstraint( ... parameters=["A", "B"], - ... condition=ThresholdCondition(threshold=5.0, operator="<="), ... coefficients=(2.0, 1.0), + ... operator="<=", + ... rhs=5.0, ... ) >>> list(c.get_invalid(df)) [1, 2] """ - # IMPROVE: refactor `SumConstraint` and `ProdConstraint` to avoid code copying - # IMPROVE: Look-ahead filtering would be possible if parameter # value ranges (min/max) were available to the constraint, allowing # bound-based pruning of partial sums before all parameters are @@ -189,8 +192,8 @@ class DiscreteSumConstraint(DiscreteFilteringConstraint): # See base class. # object variables - condition: ThresholdCondition = field() - """The condition modeled by this constraint.""" + operator: str = field(validator=in_(_threshold_operators)) + """The comparison operator (e.g. ``"="``, ``">="``, ``"<"``).""" coefficients: tuple[float, ...] = field( converter=lambda x: cattrs.structure(x, tuple[float, ...]), @@ -200,6 +203,17 @@ class DiscreteSumConstraint(DiscreteFilteringConstraint): Defaults to all-ones, i.e. an unweighted sum.""" + rhs: float = field(default=0.0, converter=float, validator=finite_float) + """Right-hand side value of the comparison.""" + + tolerance: float | None = field( + default=None, converter=lambda x: float(x) if x is not None else None + ) + """Numerical tolerance for equality/inequality operators that support it. + + Only applicable when ``operator`` is one of ``"="``, ``"=="``, ``"!="``. + Set to a reasonable default when left as ``None``.""" + @coefficients.default def _default_coefficients(self) -> tuple[float, ...]: """Return equal weight coefficients as default.""" @@ -223,6 +237,39 @@ def _validate_coefficients( # noqa: DOC101, DOC103 if any(c == 0.0 for c in coefficients): raise ValueError("All entries in 'coefficients' must be non-zero.") + @tolerance.validator + def _validate_tolerance( # noqa: DOC101, DOC103 + self, attribute: Any, value: float | None + ) -> None: + """Validate the tolerance. + + Raises: + ValueError: If a tolerance is provided for a non-tolerance operator. + ValueError: If the tolerance is not positive for a tolerance operator. + """ + if self.operator not in _valid_tolerance_operators and value is not None: + raise ValueError( + f"Setting the '{attribute.alias}' is only valid with the following " + f"operators: {_valid_tolerance_operators}, but got operator " + f"'{self.operator}'." + ) + if value is not None: + finite_float(self, attribute, value) + if value <= 0.0: + raise ValueError( + f"'{attribute.alias}' must be positive, but got {value}." + ) + + def _build_condition(self) -> ThresholdCondition: + """Build the internal threshold condition from the constraint fields.""" + kwargs: dict[str, Any] = { + "threshold": self.rhs, + "operator": self.operator, + } + if self.tolerance is not None: + kwargs["tolerance"] = self.tolerance + return ThresholdCondition(**kwargs) + @override def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: evaluate_df = pd.Series( @@ -231,7 +278,8 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: ), index=df.index, ) - mask_good = self.condition.evaluate(evaluate_df) + condition = self._build_condition() + mask_good = condition.evaluate(evaluate_df) return df.index[mask_good] @@ -240,12 +288,17 @@ def _get_matching_rows_polars(self, schema: pl.Schema) -> pl.Expr: from baybe._optional.polars import polars as pl weighted = [pl.col(p) * c for p, c in zip(self.parameters, self.coefficients)] - return self.condition.to_polars(pl.sum_horizontal(weighted)) + condition = self._build_condition() + return condition.to_polars(pl.sum_horizontal(weighted)) @define class DiscreteProductConstraint(DiscreteFilteringConstraint): - """Class for modelling product constraints. + """Class for modelling product constraints on discrete parameters. + + The constraint compares the product of the specified parameters against + :paramref:`DiscreteProductConstraint.rhs` using + :paramref:`DiscreteProductConstraint.operator`. Examples: >>> df = pd.DataFrame({"A": [2.0, 3.0, 5.0], "B": [3.0, 2.0, 2.0]}) @@ -256,31 +309,120 @@ class DiscreteProductConstraint(DiscreteFilteringConstraint): 2 5.0 2.0 >>> c = DiscreteProductConstraint( ... parameters=["A", "B"], - ... condition=ThresholdCondition(threshold=8.0, operator="<="), + ... operator="<=", + ... rhs=8.0, ... ) >>> list(c.get_invalid(df)) [2] """ - # IMPROVE: refactor `SumConstraint` and `ProdConstraint` to avoid code copying + # IMPROVE: Look-ahead filtering would be possible if parameter + # value ranges (min/max) were available to the constraint, allowing + # bound-based pruning of partial products before all parameters are + # present. This could be expressed via a _can_evaluate override. # class variables numerical_only: ClassVar[bool] = True # See base class. # object variables - condition: ThresholdCondition = field() - """The condition that is used for this constraint.""" + # >>>>>>>>>> Deprecation + # NOTE: `condition` occupies its original (second) positional slot so that the + # previously valid call `DiscreteProductConstraint(parameters, condition)` keeps + # working (with a deprecation warning). The new-interface fields are therefore + # keyword-only until the deprecated `condition` field is removed. + condition: ThresholdCondition | None = field(default=None) + """Deprecated. Use keywords ``operator``, ``rhs``, and ``tolerance`` instead.""" - # IMPROVE: Look-ahead filtering would be possible if parameter - # value ranges (min/max) were available to the constraint, allowing - # bound-based pruning of partial products before all parameters are - # present. This could be expressed via a _can_evaluate override. + # <<<<<<<<<< Deprecation + + operator: str = field(default="", validator=instance_of(str), kw_only=True) + """The comparison operator (e.g. ``"="``, ``">="``, ``"<"``).""" + + rhs: float = field( + default=0.0, converter=float, validator=finite_float, kw_only=True + ) + """Right-hand side value of the comparison.""" + + tolerance: float | None = field( + default=None, + converter=lambda x: float(x) if x is not None else None, + kw_only=True, + ) + """Numerical tolerance for equality/inequality operators that support it. + + Only applicable when ``operator`` is one of ``"="``, ``"=="``, ``"!="``. + Set to a reasonable default when left as ``None``.""" + + def __attrs_post_init__(self): + """Resolve the deprecated ``condition`` field and validate.""" + import warnings + + flds = fields(type(self)) + + # >>>>>>>>>> Deprecation + if self.condition is not None: + if self.operator != "": + raise ValueError( + f"Cannot specify both '{flds.condition.alias}' and " + f"'{flds.operator.alias}'. Use the new interface " + f"('{flds.operator.alias}', '{flds.rhs.alias}', " + f"'{flds.tolerance.alias}') instead." + ) + warnings.warn( + f"Passing '{flds.condition.alias}' to '{type(self).__name__}' is " + f"deprecated and will be removed in a future version. Use " + f"'{flds.operator.alias}' and '{flds.rhs.alias}' (and optionally " + f"'{flds.tolerance.alias}') instead.", + DeprecationWarning, + stacklevel=2, + ) + object.__setattr__(self, "operator", self.condition.operator) + object.__setattr__(self, "rhs", self.condition.threshold) + object.__setattr__(self, "tolerance", self.condition.tolerance) + object.__setattr__(self, "condition", None) + # <<<<<<<<<< Deprecation + + # Validate operator + if self.operator not in _threshold_operators: + raise ValueError( + f"'{flds.operator.alias}' must be one of " + f"{list(_threshold_operators)}, but got '{self.operator}'." + ) + + # Validate tolerance + if ( + self.operator not in _valid_tolerance_operators + and self.tolerance is not None + ): + raise ValueError( + f"Setting the '{flds.tolerance.alias}' is only valid with the " + f"following operators: {_valid_tolerance_operators}, but got " + f"operator '{self.operator}'." + ) + if self.tolerance is not None: + finite_float(self, flds.tolerance, self.tolerance) + if self.tolerance <= 0.0: + raise ValueError( + f"'{flds.tolerance.alias}' must be positive, " + f"but got {self.tolerance}." + ) + + def _build_condition(self) -> ThresholdCondition: + """Build the internal threshold condition from the constraint fields.""" + kwargs: dict[str, Any] = { + "threshold": self.rhs, + "operator": self.operator, + } + if self.tolerance is not None: + kwargs["tolerance"] = self.tolerance + return ThresholdCondition(**kwargs) @override def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: evaluate_df = df[self.parameters].prod(axis=1) - mask_good = self.condition.evaluate(evaluate_df) + condition = self._build_condition() + mask_good = condition.evaluate(evaluate_df) return df.index[mask_good] @@ -288,13 +430,49 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: def _get_matching_rows_polars(self, schema: pl.Schema) -> pl.Expr: from baybe._optional.polars import polars as pl - op = _threshold_operators[self.condition.operator] - - # Get the product of columns + condition = self._build_condition() expr = pl.reduce(lambda acc, x: acc * x, pl.col(self.parameters)) + return condition.to_polars(expr) + + +# >>>>>>>>>> Deprecation +def DiscreteSumConstraint( # noqa: N802 + parameters, condition=None, coefficients=None, *, exclude=False +) -> DiscreteLinearConstraint: + """A ``DiscreteLinearConstraint`` alias for backward compatibility.""" # noqa: D401 + import warnings + + warnings.warn( + f"'DiscreteSumConstraint' is deprecated and will be removed in a future " + f"version. Use '{DiscreteLinearConstraint.__name__}' instead.", + DeprecationWarning, + stacklevel=2, + ) + # Translate the old ThresholdCondition-based interface + if condition is not None: + operator = condition.operator + rhs = condition.threshold + tolerance = condition.tolerance + else: + raise TypeError( + f"Missing required argument 'condition'. Use " + f"'{DiscreteLinearConstraint.__name__}' with 'operator' and 'rhs' instead." + ) - # Apply the threshold operator on expr and the condition threshold - return op(expr, self.condition.threshold) + new_kwargs: dict[str, Any] = { + "operator": operator, + "rhs": rhs, + "exclude": exclude, + } + if tolerance is not None: + new_kwargs["tolerance"] = tolerance + if coefficients is not None: + new_kwargs["coefficients"] = coefficients + + return DiscreteLinearConstraint(parameters, **new_kwargs) + + +# <<<<<<<<<< Deprecation @define @@ -814,7 +992,7 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: DISCRETE_CONSTRAINTS_FILTERING_ORDER = ( DiscreteSelectionConstraint, DiscreteRepetitionConstraint, - DiscreteSumConstraint, + DiscreteLinearConstraint, DiscreteProductConstraint, DiscreteCardinalityConstraint, DiscreteCustomConstraint, @@ -828,20 +1006,64 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: # >>>>>>>>>> Deprecation +def _unstructure_product_constraint(obj: DiscreteProductConstraint) -> dict: + """Unstructure hook that excludes the deprecated ``condition`` field.""" + result = cattrs.gen.make_dict_unstructure_fn(DiscreteProductConstraint, converter)( + obj + ) + result.pop("condition", None) + return result + + +converter.register_unstructure_hook( + DiscreteProductConstraint, _unstructure_product_constraint +) + + +def _unpack_condition_payload(val: dict) -> None: + """Unpack a legacy nested ``condition`` dict into top-level fields. + + Mutates *val* in place: extracts ``threshold`` → ``rhs``, + ``operator`` → ``operator``, and (optionally) ``tolerance`` → ``tolerance`` + from the nested ``condition`` sub-dict, then removes the ``condition`` key. + + Args: + val: The serialized constraint dict to transform. + """ + cond = val.pop("condition", None) + if cond is None: + return + if isinstance(cond, dict): + cond = dict(cond) + # Remove the type discriminator if present + cond.pop("type", None) + val["operator"] = cond["operator"] + val["rhs"] = cond["threshold"] + tol = cond.get("tolerance") + if tol is not None: + val["tolerance"] = tol + + def _structure_constraint_compat(val: dict, cls: type) -> Constraint: """Structure hook that redirects legacy constraint type names.""" val = dict(val) # copy before mutating - if val.get(_TYPE_FIELD) == "DiscreteExcludeConstraint": + type_ = val.get(_TYPE_FIELD) + if type_ == "DiscreteExcludeConstraint": val[_TYPE_FIELD] = "DiscreteSelectionConstraint" val["exclude"] = True - elif val.get(_TYPE_FIELD) == "DiscreteNoLabelDuplicatesConstraint": + elif type_ == "DiscreteNoLabelDuplicatesConstraint": val[_TYPE_FIELD] = "DiscreteRepetitionConstraint" val["n_max_repetitions"] = 1 - elif val.get(_TYPE_FIELD) == "DiscreteLinkedParametersConstraint": + elif type_ == "DiscreteLinkedParametersConstraint": val[_TYPE_FIELD] = "DiscreteRepetitionConstraint" if (params := val.get("parameters")) is not None and len(params) >= 2: val["n_max_repetitions"] = len(params) - 1 val["exclude"] = True + elif type_ == "DiscreteSumConstraint": + _unpack_condition_payload(val) + val[_TYPE_FIELD] = "DiscreteLinearConstraint" + elif type_ == "DiscreteProductConstraint" and "condition" in val: + _unpack_condition_payload(val) return make_base_structure_hook(cls)(val, cls) diff --git a/docs/components/constraints.md b/docs/components/constraints.md index 0e9da29dcd..96e234ff30 100644 --- a/docs/components/constraints.md +++ b/docs/components/constraints.md @@ -273,24 +273,35 @@ DiscreteSelectionConstraint( A more detailed example can be found [here](../../examples/Constraints_Discrete/selection_constraints). -#### DiscreteSumConstraint and DiscreteProductConstraint -[`DiscreteSumConstraint`](baybe.constraints.discrete.DiscreteSumConstraint) +#### DiscreteLinearConstraint and DiscreteProductConstraint +[`DiscreteLinearConstraint`](baybe.constraints.discrete.DiscreteLinearConstraint) and [`DiscreteProductConstraint`](baybe.constraints.discrete.DiscreteProductConstraint) -impose conditions on sums or products of numerical parameters. +impose conditions on weighted sums or products of numerical parameters. +The `DiscreteLinearConstraint` mirrors the interface of +[`ContinuousLinearConstraint`](#CLC) with `operator`, `rhs`, and `coefficients`. [In the first example from `ContinuousLinearConstraint`](#CLC), we had three continuous parameters `x_1`, `x_2` and `x_3`, which needed to sum up to 1.0. If these parameters were instead discrete, the corresponding constraint would look like: ```python -from baybe.constraints import DiscreteSumConstraint, ThresholdCondition +from baybe.constraints import DiscreteLinearConstraint -DiscreteSumConstraint( +DiscreteLinearConstraint( parameters=["x_1", "x_2", "x_3"], - condition=ThresholdCondition( # set condition that should apply to the sum - threshold=1.0, - operator="=", - tolerance=0.001, # optional; here, everything between 0.999 and 1.001 would also be considered valid - ), + operator="=", + rhs=1.0, + tolerance=0.001, # optional; everything between 0.999 and 1.001 is valid +) +``` + +A product constraint can be expressed similarly: +```python +from baybe.constraints import DiscreteProductConstraint + +DiscreteProductConstraint( + parameters=["x_1", "x_2"], + operator=">=", + rhs=30.0, ) ``` diff --git a/examples/Constraints_Continuous/hybrid_space.py b/examples/Constraints_Continuous/hybrid_space.py index 4ae3b4bc8a..070ef93fcc 100644 --- a/examples/Constraints_Continuous/hybrid_space.py +++ b/examples/Constraints_Continuous/hybrid_space.py @@ -19,8 +19,7 @@ from baybe import Campaign from baybe.constraints import ( ContinuousLinearConstraint, - DiscreteSumConstraint, - ThresholdCondition, + DiscreteLinearConstraint, ) from baybe.parameters import NumericalContinuousParameter, NumericalDiscreteParameter from baybe.searchspace import SearchSpace @@ -76,11 +75,11 @@ # - $1.0*x_3 - 1.0*x_4 = 2.0$ constraints = [ - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=["x_1", "x_2"], - condition=ThresholdCondition( - threshold=1.0, operator="==", tolerance=STRIDE / 2.0 - ), + operator="==", + rhs=1.0, + tolerance=STRIDE / 2.0, ), ContinuousLinearConstraint( parameters=["x_3", "x_4"], operator="=", coefficients=(1.0, -1.0), rhs=2.0 diff --git a/examples/Constraints_Discrete/prodsum_constraints.py b/examples/Constraints_Discrete/prodsum_constraints.py index ff482cdc49..0414959f47 100644 --- a/examples/Constraints_Discrete/prodsum_constraints.py +++ b/examples/Constraints_Discrete/prodsum_constraints.py @@ -13,9 +13,8 @@ from baybe import Campaign from baybe.constraints import ( + DiscreteLinearConstraint, DiscreteProductConstraint, - DiscreteSumConstraint, - ThresholdCondition, ) from baybe.objectives import SingleTargetObjective from baybe.parameters import ( @@ -77,17 +76,21 @@ # Constraints are used when creating the searchspace object. # Thus, they need to be defined prior to the searchspace creation. -sum_constraint_1 = DiscreteSumConstraint( +sum_constraint_1 = DiscreteLinearConstraint( parameters=["NumParam1", "NumParam2"], - condition=ThresholdCondition(threshold=150.0, operator="<="), + operator="<=", + rhs=150.0, ) -sum_constraint_2 = DiscreteSumConstraint( +sum_constraint_2 = DiscreteLinearConstraint( parameters=["NumParam5", "NumParam6"], - condition=ThresholdCondition(threshold=100, operator="=", tolerance=1.0), + operator="=", + rhs=100, + tolerance=1.0, ) prod_constraint = DiscreteProductConstraint( parameters=["NumParam3", "NumParam4"], - condition=ThresholdCondition(threshold=30, operator=">="), + operator=">=", + rhs=30, ) constraints = [sum_constraint_1, sum_constraint_2, prod_constraint] diff --git a/examples/Mixtures/slot_based.py b/examples/Mixtures/slot_based.py index 5386e39ccd..b4b5ce8126 100644 --- a/examples/Mixtures/slot_based.py +++ b/examples/Mixtures/slot_based.py @@ -48,9 +48,9 @@ from baybe.constraints import ( DiscreteDependenciesConstraint, + DiscreteLinearConstraint, DiscretePermutationInvarianceConstraint, DiscreteRepetitionConstraint, - DiscreteSumConstraint, ThresholdCondition, ) from baybe.parameters import NumericalDiscreteParameter, SubstanceParameter @@ -168,9 +168,11 @@ # Interpreting the slot amounts as percentages, we need to ensure that their total is # always 100: -sum_constraint = DiscreteSumConstraint( +sum_constraint = DiscreteLinearConstraint( parameters=["Slot1_Amount", "Slot2_Amount", "Slot3_Amount"], - condition=ThresholdCondition(threshold=100, operator="=", tolerance=SUM_TOLERANCE), + operator="=", + rhs=100, + tolerance=SUM_TOLERANCE, ) diff --git a/tests/conftest.py b/tests/conftest.py index 7a28450a76..df9e85a1dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,11 +33,11 @@ DiscreteCardinalityConstraint, DiscreteCustomConstraint, DiscreteDependenciesConstraint, + DiscreteLinearConstraint, DiscretePermutationInvarianceConstraint, DiscreteProductConstraint, DiscreteRepetitionConstraint, DiscreteSelectionConstraint, - DiscreteSumConstraint, SubSelectionCondition, ThresholdCondition, ) @@ -509,17 +509,20 @@ def custom_function(df: pd.DataFrame) -> pd.Series: parameters=["Solvent_1", "Solvent_2", "Solvent_3"], n_max_repetitions=1, ), - "Constraint_8": DiscreteSumConstraint( + "Constraint_8": DiscreteLinearConstraint( parameters=["Fraction_1", "Fraction_2"], - condition=ThresholdCondition(threshold=150, operator="<="), + operator="<=", + rhs=150, ), "Constraint_9": DiscreteProductConstraint( parameters=["Fraction_1", "Fraction_2"], - condition=ThresholdCondition(threshold=30, operator=">="), + operator=">=", + rhs=30, ), - "Constraint_10": DiscreteSumConstraint( + "Constraint_10": DiscreteLinearConstraint( parameters=["Fraction_1", "Fraction_2"], - condition=ThresholdCondition(threshold=100, operator="="), + operator="=", + rhs=100, ), "Constraint_11": DiscretePermutationInvarianceConstraint( parameters=["Solvent_1", "Solvent_2", "Solvent_3"], @@ -535,9 +538,11 @@ def custom_function(df: pd.DataFrame) -> pd.Series: affected_parameters=[["Solvent_1"], ["Solvent_2"], ["Solvent_3"]], ), ), - "Constraint_12": DiscreteSumConstraint( + "Constraint_12": DiscreteLinearConstraint( parameters=["Fraction_1", "Fraction_2", "Fraction_3"], - condition=ThresholdCondition(threshold=100, operator="=", tolerance=0.01), + operator="=", + rhs=100, + tolerance=0.01, ), "Constraint_13": DiscreteCustomConstraint( parameters=["Pressure", "Solvent_1", "Temperature"], diff --git a/tests/constraints/test_constrained_cartesian_product.py b/tests/constraints/test_constrained_cartesian_product.py index 5f72927c67..f5c0d74e36 100644 --- a/tests/constraints/test_constrained_cartesian_product.py +++ b/tests/constraints/test_constrained_cartesian_product.py @@ -13,10 +13,10 @@ DISCRETE_CONSTRAINTS_FILTERING_ORDER, DiscreteCardinalityConstraint, DiscreteDependenciesConstraint, + DiscreteLinearConstraint, DiscretePermutationInvarianceConstraint, DiscreteRepetitionConstraint, DiscreteSelectionConstraint, - DiscreteSumConstraint, SubSelectionCondition, ThresholdCondition, ) @@ -129,9 +129,11 @@ def _sum_scenario() -> tuple[Sequence[DiscreteParameter], Sequence[DiscreteConst for i in range(3) ] constraints = [ - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=[p.name for p in params], - condition=ThresholdCondition(threshold=100, operator="=", tolerance=0.1), + operator="=", + rhs=100, + tolerance=0.1, ) ] return params, constraints @@ -196,9 +198,11 @@ def _permutation_invariance_with_dependencies_scenario() -> tuple[ affected_parameters=[[n] for n in label_names], ), ), - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=amount_names, - condition=ThresholdCondition(threshold=100, operator="=", tolerance=0.1), + operator="=", + rhs=100, + tolerance=0.1, ), DiscreteRepetitionConstraint(parameters=label_names, n_max_repetitions=1), ] @@ -219,9 +223,10 @@ def _mixed_scenario() -> tuple[ DiscreteRepetitionConstraint( parameters=["Cat1", "Cat2", "Cat3"], n_max_repetitions=1 ), - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=["Num1", "Num2"], - condition=ThresholdCondition(threshold=100, operator="<="), + operator="<=", + rhs=100, ), ] return params, constraints diff --git a/tests/constraints/test_constraints_discrete.py b/tests/constraints/test_constraints_discrete.py index fb850aae91..01b1202d7b 100644 --- a/tests/constraints/test_constraints_discrete.py +++ b/tests/constraints/test_constraints_discrete.py @@ -8,7 +8,10 @@ from pytest import param from baybe.constraints.conditions import ThresholdCondition -from baybe.constraints.discrete import DiscreteSumConstraint +from baybe.constraints.discrete import ( + DiscreteLinearConstraint, + DiscreteSelectionConstraint, +) @pytest.fixture( @@ -294,11 +297,12 @@ def test_cardinality(campaign): ], ) def test_sum_constraint_coefficients(coefficients, threshold, operator, n_invalid): - """DiscreteSumConstraint filters correctly with default and custom coefficients.""" + """DiscreteLinearConstraint filters with default and custom coefficients.""" kwargs = {} if coefficients is None else {"coefficients": coefficients} - constraint = DiscreteSumConstraint( + constraint = DiscreteLinearConstraint( parameters=["A", "B"], - condition=ThresholdCondition(threshold=threshold, operator=operator), + operator=operator, + rhs=threshold, **kwargs, ) df = pd.DataFrame( @@ -309,3 +313,61 @@ def test_sum_constraint_coefficients(coefficients, threshold, operator, n_invali expected = df.index[~ThresholdCondition(threshold, operator).evaluate(weighted)] assert list(constraint.get_invalid(df)) == list(expected) assert len(constraint.get_invalid(df)) == n_invalid + + +@pytest.mark.parametrize( + ("combiner", "exclude", "partial_ok"), + [ + param("AND", False, True, id="AND-keep"), + param("AND", True, False, id="AND-exclude"), + param("OR", False, False, id="OR-keep"), + param("OR", True, True, id="OR-exclude"), + param("XOR", False, False, id="XOR-keep"), + param("XOR", True, False, id="XOR-exclude"), + ], +) +def test_filtering_partial_evaluation(combiner, exclude, partial_ok): + """Partial evaluation is only allowed when the drop decision cannot reverse. + + In particular, an ``XOR`` combination must never be evaluated partially, since + its result can flip as further operands become available. + """ + constraint = DiscreteSelectionConstraint( + parameters=["A", "B"], + conditions=[ + ThresholdCondition(threshold=0.0, operator=">"), + ThresholdCondition(threshold=0.0, operator=">"), + ], + combiner=combiner, + exclude=exclude, + ) + # Only a subset of the involved parameters is available + assert constraint._can_evaluate({"A"}) is partial_ok + # All parameters available -> always evaluable + assert constraint._can_evaluate({"A", "B"}) is True + + +def test_filtering_xor_partial_does_not_drop_valid_rows(): + """A valid XOR row is not removed when evaluated with missing columns. + + With ``allow_missing=True`` and only one operand present, a premature XOR + evaluation would wrongly drop rows; the constraint must instead defer. + """ + constraint = DiscreteSelectionConstraint( + parameters=["A", "B"], + conditions=[ + ThresholdCondition(threshold=0.0, operator=">"), + ThresholdCondition(threshold=0.0, operator=">"), + ], + combiner="XOR", + ) + # Row where only "A" is known so far; "B" is still missing. + partial_df = pd.DataFrame({"A": [1.0, 0.0]}) + # Deferred: nothing may be dropped yet. + assert list(constraint.get_invalid(partial_df, allow_missing=True)) == [] + + # Once both columns are present, XOR keeps rows where exactly one holds. + full_df = pd.DataFrame({"A": [1.0, 1.0, 0.0], "B": [0.0, 1.0, 0.0]}) + invalid = constraint.get_invalid(full_df, allow_missing=True) + # Rows 1 (both > 0) and 2 (neither > 0) violate XOR; row 0 is kept. + assert list(invalid) == [1, 2] diff --git a/tests/constraints/test_constraints_polars.py b/tests/constraints/test_constraints_polars.py index 65eca94fc5..82dd4ffa30 100644 --- a/tests/constraints/test_constraints_polars.py +++ b/tests/constraints/test_constraints_polars.py @@ -7,7 +7,7 @@ from baybe._optional.info import POLARS_INSTALLED from baybe.constraints import ( DiscreteCustomConstraint, - DiscreteSumConstraint, + DiscreteLinearConstraint, ThresholdCondition, ) from baybe.parameters import NumericalDiscreteParameter @@ -86,8 +86,9 @@ def test_polars_sum_constraint(parameters, coefficients, threshold, operator): """Polars and Pandas paths produce correct and identical results.""" names = [p.name for p in parameters] kwargs = {} if coefficients is None else {"coefficients": coefficients} - condition = ThresholdCondition(threshold=threshold, operator=operator) - constraint = DiscreteSumConstraint(parameters=names, condition=condition, **kwargs) + constraint = DiscreteLinearConstraint( + parameters=names, operator=operator, rhs=threshold, **kwargs + ) coeffs = coefficients or (1.0,) * len(parameters) ldf = _lazyframe_from_product(parameters) @@ -97,6 +98,7 @@ def test_polars_sum_constraint(parameters, coefficients, threshold, operator): df_pl = _apply_constraint_filter_polars(ldf, [constraint]).collect().to_pandas() # Correctness: all remaining rows satisfy the constraint + condition = ThresholdCondition(threshold=threshold, operator=operator) weighted_pd = sum(df_pd[n] * c for n, c in zip(names, coeffs)) assert condition.evaluate(weighted_pd).all() @@ -231,9 +233,10 @@ def test_mixed_polars_pandas_constraints(): ] constraints = [ # Polars-capable: operates on [A, B] - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=["A", "B"], - condition=ThresholdCondition(threshold=100, operator="="), + operator="=", + rhs=100, ), # Pandas-only: operates on [B, C] — B is shared with the Polars constraint DiscreteCustomConstraint( diff --git a/tests/hypothesis_strategies/alternative_creation/test_searchspace.py b/tests/hypothesis_strategies/alternative_creation/test_searchspace.py index e5ccbde0b0..783f39fe1f 100644 --- a/tests/hypothesis_strategies/alternative_creation/test_searchspace.py +++ b/tests/hypothesis_strategies/alternative_creation/test_searchspace.py @@ -10,8 +10,7 @@ from pandas.testing import assert_frame_equal from pytest import param -from baybe.constraints.conditions import ThresholdCondition -from baybe.constraints.discrete import DiscreteSumConstraint +from baybe.constraints.discrete import DiscreteLinearConstraint from baybe.parameters import ( CategoricalParameter, NumericalContinuousParameter, @@ -283,9 +282,10 @@ def test_discrete_space_creation_from_simplex_coefficients( # from_product with equivalent constraint operator = "=" if boundary_only else "<=" - constraint = DiscreteSumConstraint( + constraint = DiscreteLinearConstraint( parameters=cols, - condition=ThresholdCondition(threshold=max_sum, operator=operator), + operator=operator, + rhs=max_sum, coefficients=tuple(coeffs), ) result_product = ( diff --git a/tests/hypothesis_strategies/constraints.py b/tests/hypothesis_strategies/constraints.py index 2fff8ed78a..647a21960b 100644 --- a/tests/hypothesis_strategies/constraints.py +++ b/tests/hypothesis_strategies/constraints.py @@ -6,18 +6,20 @@ from hypothesis import assume from baybe.constraints.conditions import ( + _threshold_operators, _valid_logic_combiners, + _valid_tolerance_operators, ) from baybe.constraints.continuous import ( ContinuousLinearConstraint, ) from baybe.constraints.discrete import ( DiscreteDependenciesConstraint, + DiscreteLinearConstraint, DiscretePermutationInvarianceConstraint, DiscreteProductConstraint, DiscreteRepetitionConstraint, DiscreteSelectionConstraint, - DiscreteSumConstraint, ) from baybe.parameters.base import DiscreteParameter from baybe.parameters.numerical import NumericalDiscreteParameter @@ -169,12 +171,11 @@ def discrete_permutation_invariance_constraints( @st.composite -def _discrete_constraints( +def discrete_linear_constraints( draw: st.DrawFn, - constraint_type: type[DiscreteSumConstraint] | type[DiscreteProductConstraint], parameter_names: list[str] | None = None, ): - """Generate discrete sum/product constraints.""" + """Generate :class:`baybe.constraints.discrete.DiscreteLinearConstraint`.""" if parameter_names is None: params = draw(st.lists(st.text(), unique=True, min_size=1)) else: @@ -182,27 +183,56 @@ def _discrete_constraints( assert len(parameter_names) == len(set(parameter_names)) params = parameter_names + operator = draw(st.sampled_from(list(_threshold_operators.keys()))) + rhs = draw(finite_floats()) exclude = draw(st.booleans()) - if constraint_type is DiscreteSumConstraint: - condition = draw(threshold_conditions()) - if draw(st.booleans()): - coefficients = draw(st.tuples(*([_nonzero_finite_floats] * len(params)))) - return DiscreteSumConstraint( - params, condition, coefficients, exclude=exclude - ) - return DiscreteSumConstraint(params, condition, exclude=exclude) - else: - return DiscreteProductConstraint( - params, draw(threshold_conditions()), exclude=exclude + # Optionally add tolerance for tolerance-enabled operators + tolerance = None + if operator in _valid_tolerance_operators: + tolerance = draw(st.one_of(st.none(), finite_floats().filter(lambda x: x > 0))) + + # Optionally add coefficients + if draw(st.booleans()): + coefficients = draw(st.tuples(*([_nonzero_finite_floats] * len(params)))) + return DiscreteLinearConstraint( + params, + operator, + coefficients, + rhs=rhs, + tolerance=tolerance, + exclude=exclude, ) + return DiscreteLinearConstraint( + params, operator, rhs=rhs, tolerance=tolerance, exclude=exclude + ) -discrete_sum_constraints = partial(_discrete_constraints, DiscreteSumConstraint) -"""Generate :class:`baybe.constraints.discrete.DiscreteSumConstraint`.""" +@st.composite +def discrete_product_constraints( + draw: st.DrawFn, + parameter_names: list[str] | None = None, +): + """Generate :class:`baybe.constraints.discrete.DiscreteProductConstraint`.""" + if parameter_names is None: + params = draw(st.lists(st.text(), unique=True, min_size=1)) + else: + assert len(parameter_names) > 0 + assert len(parameter_names) == len(set(parameter_names)) + params = parameter_names -discrete_product_constraints = partial(_discrete_constraints, DiscreteProductConstraint) -"""Generate :class:`baybe.constraints.discrete.DiscreteProductConstraint`.""" + operator = draw(st.sampled_from(list(_threshold_operators.keys()))) + rhs = draw(finite_floats()) + exclude = draw(st.booleans()) + + # Optionally add tolerance for tolerance-enabled operators + tolerance = None + if operator in _valid_tolerance_operators: + tolerance = draw(st.one_of(st.none(), finite_floats().filter(lambda x: x > 0))) + + return DiscreteProductConstraint( + params, operator=operator, rhs=rhs, tolerance=tolerance, exclude=exclude + ) @st.composite @@ -264,7 +294,7 @@ def continuous_linear_constraints( discrete_selection_constraints(), discrete_dependencies_constraints(), discrete_permutation_invariance_constraints(), - discrete_sum_constraints(), + discrete_linear_constraints(), discrete_product_constraints(), discrete_repetition_constraints(), continuous_linear_equality_constraints(), diff --git a/tests/serialization/test_constraint_serialization.py b/tests/serialization/test_constraint_serialization.py index a0c0d8f07a..13f53eb0af 100644 --- a/tests/serialization/test_constraint_serialization.py +++ b/tests/serialization/test_constraint_serialization.py @@ -8,11 +8,11 @@ from tests.hypothesis_strategies.constraints import ( continuous_linear_constraints, discrete_dependencies_constraints, + discrete_linear_constraints, discrete_permutation_invariance_constraints, discrete_product_constraints, discrete_repetition_constraints, discrete_selection_constraints, - discrete_sum_constraints, ) from tests.serialization.utils import assert_roundtrip_consistency @@ -26,7 +26,7 @@ ), param(discrete_dependencies_constraints(), id="DiscreteDependenciesConstraint"), param(discrete_selection_constraints(), id="DiscreteSelectionConstraint"), - param(discrete_sum_constraints(), id="DiscreteSumConstraint"), + param(discrete_linear_constraints(), id="DiscreteLinearConstraint"), param(discrete_product_constraints(), id="DiscreteProductConstraint"), param( discrete_repetition_constraints(), diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 197fdde97f..b2e7c79132 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -3,6 +3,7 @@ import os import warnings from contextlib import nullcontext +from copy import deepcopy from itertools import pairwise from pathlib import Path from unittest.mock import patch @@ -15,13 +16,16 @@ from pytest import param from baybe._optional.info import CHEM_INSTALLED, POLARS_INSTALLED -from baybe.constraints import SubSelectionCondition +from baybe.constraints import SubSelectionCondition, ThresholdCondition from baybe.constraints import base as base_module from baybe.constraints import discrete as discrete_module from baybe.constraints.discrete import ( DiscreteExcludeConstraint, + DiscreteLinearConstraint, + DiscreteProductConstraint, DiscreteRepetitionConstraint, DiscreteSelectionConstraint, + DiscreteSumConstraint, ) from baybe.exceptions import DeprecationError from baybe.kernels.basic import MaternKernel @@ -567,70 +571,91 @@ def test_multitask_kernel_deprecation(monkeypatch, custom: bool, env: bool, task GaussianProcessSurrogate(*args).fit(searchspace, objective, measurements) -def test_discrete_exclude_constraint_deprecation(): - """Constructing a DiscreteExcludeConstraint emits a DeprecationWarning.""" - with pytest.warns(DeprecationWarning, match="DiscreteExcludeConstraint"): - c = DiscreteExcludeConstraint( - parameters=["A"], - conditions=[SubSelectionCondition(selection=["a"])], - ) - ref = DiscreteSelectionConstraint( - parameters=["A"], - conditions=[SubSelectionCondition(selection=["a"])], - exclude=True, - ) - assert c == ref - - -@pytest.mark.parametrize( - "annotation", - ["Constraint", "DiscreteConstraint", "DiscreteFilteringConstraint"], -) -def test_discrete_exclude_constraint_deserialization(annotation): - """Legacy DiscreteExcludeConstraint deserializes regardless of the annotation.""" - legacy_dict = { - "type": "DiscreteExcludeConstraint", - "parameters": ["A"], - "conditions": [{"type": "SubSelectionCondition", "selection": ["a"]}], - "combiner": "AND", - } - ref = DiscreteSelectionConstraint( - parameters=["A"], - conditions=[SubSelectionCondition(selection=["a"])], - combiner="AND", - exclude=True, - ) - target = getattr(base_module, annotation) - result = converter.structure(legacy_dict, target) - assert result == ref - - @pytest.mark.parametrize( - ("legacy_name", "kwargs", "expected"), + ("factory", "args", "kwargs", "warning_match", "expected"), [ pytest.param( - "DiscreteNoLabelDuplicatesConstraint", + DiscreteExcludeConstraint, + (), + { + "parameters": ["A"], + "conditions": [SubSelectionCondition(selection=["a"])], + }, + "DiscreteExcludeConstraint", + DiscreteSelectionConstraint( + parameters=["A"], + conditions=[SubSelectionCondition(selection=["a"])], + exclude=True, + ), + id="exclude", + ), + pytest.param( + discrete_module.DiscreteNoLabelDuplicatesConstraint, + (), {"parameters": ["A", "B", "C"]}, - {"n_max_repetitions": 1}, - id="no_label_duplicates", + "DiscreteNoLabelDuplicatesConstraint", + DiscreteRepetitionConstraint( + parameters=["A", "B", "C"], n_max_repetitions=1 + ), + id="no-label-duplicates", ), pytest.param( - "DiscreteLinkedParametersConstraint", + discrete_module.DiscreteLinkedParametersConstraint, + (), {"parameters": ["A", "B", "C"]}, - {"n_max_repetitions": 2, "exclude": True}, - id="linked_parameters", + "DiscreteLinkedParametersConstraint", + DiscreteRepetitionConstraint( + parameters=["A", "B", "C"], + n_max_repetitions=2, + exclude=True, + ), + id="linked-parameters", + ), + pytest.param( + DiscreteSumConstraint, + (), + { + "parameters": ["A", "B"], + "condition": ThresholdCondition(threshold=100.0, operator="<="), + "coefficients": (2.0, 3.0), + }, + "DiscreteSumConstraint", + DiscreteLinearConstraint( + parameters=["A", "B"], + operator="<=", + rhs=100.0, + coefficients=(2.0, 3.0), + ), + id="sum", + ), + pytest.param( + DiscreteProductConstraint, + (), + { + "parameters": ["A", "B"], + "condition": ThresholdCondition(threshold=30.0, operator=">="), + }, + "condition", + DiscreteProductConstraint(parameters=["A", "B"], operator=">=", rhs=30.0), + id="product-condition-keyword", + ), + pytest.param( + DiscreteProductConstraint, + (["A", "B"], ThresholdCondition(threshold=30.0, operator=">=")), + {}, + "condition", + DiscreteProductConstraint(parameters=["A", "B"], operator=">=", rhs=30.0), + id="product-condition-positional", ), ], ) -def test_repetition_constraint_deprecation(legacy_name, kwargs, expected): +def test_discrete_constraint_deprecation( + factory, args, kwargs, warning_match, expected +): """Constructing deprecated constraints emits a deprecation warning.""" - with pytest.warns(DeprecationWarning, match=legacy_name): - c = getattr(discrete_module, legacy_name)(**kwargs) - ref = DiscreteRepetitionConstraint( - parameters=kwargs["parameters"], - **expected, - ) - assert c == ref + with pytest.warns(DeprecationWarning, match=warning_match): + result = factory(*args, **kwargs) + assert result == expected @pytest.mark.parametrize( @@ -638,30 +663,93 @@ def test_repetition_constraint_deprecation(legacy_name, kwargs, expected): ["Constraint", "DiscreteConstraint", "DiscreteFilteringConstraint"], ) @pytest.mark.parametrize( - ("legacy_name", "kwargs", "expected"), + ("payload", "expected"), [ pytest.param( - "DiscreteNoLabelDuplicatesConstraint", - {"parameters": ["A", "B", "C"]}, - {"n_max_repetitions": 1}, - id="no_label_duplicates", + { + "type": "DiscreteExcludeConstraint", + "parameters": ["A"], + "conditions": [{"type": "SubSelectionCondition", "selection": ["a"]}], + "combiner": "AND", + }, + DiscreteSelectionConstraint( + parameters=["A"], + conditions=[SubSelectionCondition(selection=["a"])], + combiner="AND", + exclude=True, + ), + id="exclude", ), pytest.param( - "DiscreteLinkedParametersConstraint", - {"parameters": ["A", "B", "C"]}, - {"n_max_repetitions": 2, "exclude": True}, - id="linked_parameters", + { + "type": "DiscreteNoLabelDuplicatesConstraint", + "parameters": ["A", "B", "C"], + }, + DiscreteRepetitionConstraint( + parameters=["A", "B", "C"], n_max_repetitions=1 + ), + id="no-label-duplicates", + ), + pytest.param( + { + "type": "DiscreteLinkedParametersConstraint", + "parameters": ["A", "B", "C"], + }, + DiscreteRepetitionConstraint( + parameters=["A", "B", "C"], + n_max_repetitions=2, + exclude=True, + ), + id="linked-parameters", + ), + pytest.param( + { + "type": "DiscreteSumConstraint", + "parameters": ["A", "B"], + "condition": { + "type": "ThresholdCondition", + "threshold": 100.0, + "operator": "=", + "tolerance": 0.01, + }, + "coefficients": [1.0, 1.0], + }, + DiscreteLinearConstraint( + parameters=["A", "B"], + operator="=", + rhs=100.0, + tolerance=0.01, + coefficients=(1.0, 1.0), + ), + id="sum", + ), + pytest.param( + { + "type": "DiscreteProductConstraint", + "parameters": ["A", "B"], + "condition": { + "type": "ThresholdCondition", + "threshold": 30.0, + "operator": ">=", + }, + }, + DiscreteProductConstraint(parameters=["A", "B"], operator=">=", rhs=30.0), + id="product-condition", ), ], ) -def test_repetition_constraint_deserialization( - annotation, legacy_name, kwargs, expected -): - """Legacy repetition constraints deserialize regardless of the annotation.""" - ref = DiscreteRepetitionConstraint( - parameters=kwargs["parameters"], - **expected, - ) +def test_discrete_constraint_deserialization(annotation, payload, expected): + """Legacy constraints deserialize regardless of the abstract annotation.""" target = getattr(base_module, annotation) - result = converter.structure({"type": legacy_name, **kwargs}, target) - assert result == ref + result = converter.structure(deepcopy(payload), target) + assert result == expected + + +def test_discrete_product_constraint_mixing_raises(): + """Passing both condition= and operator= to DiscreteProductConstraint raises.""" + with pytest.raises(ValueError, match="Cannot specify both"): + DiscreteProductConstraint( + parameters=["A", "B"], + operator=">=", + condition=ThresholdCondition(threshold=30.0, operator=">="), + ) diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py index 3de0a415a4..2a3dde4bb9 100644 --- a/tests/test_searchspace.py +++ b/tests/test_searchspace.py @@ -10,8 +10,7 @@ from baybe.constraints import ( ContinuousCardinalityConstraint, ContinuousLinearConstraint, - DiscreteSumConstraint, - ThresholdCondition, + DiscreteLinearConstraint, ) from baybe.exceptions import ( EmptySearchSpaceError, @@ -228,9 +227,10 @@ def test_invalid_constraint_parameter_combos(): SearchSpace.from_product( parameters=parameters, constraints=[ - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=["d1", "d2", "c1"], - condition=ThresholdCondition(threshold=1.0, operator=">"), + operator=">", + rhs=1.0, ) ], ) @@ -240,9 +240,10 @@ def test_invalid_constraint_parameter_combos(): SearchSpace.from_product( parameters=parameters, constraints=[ - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=["d1", "e7", "c1"], - condition=ThresholdCondition(threshold=1.0, operator=">"), + operator=">", + rhs=1.0, ) ], ) @@ -262,9 +263,10 @@ def test_invalid_constraint_parameter_combos(): SearchSpace.from_product( parameters=parameters, constraints=[ - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=["cat1", "d1", "d2"], - condition=ThresholdCondition(threshold=1.0, operator=">"), + operator=">", + rhs=1.0, ) ], ) diff --git a/tests/validation/test_constraint_validation.py b/tests/validation/test_constraint_validation.py index d36822be31..e07ef433f8 100644 --- a/tests/validation/test_constraint_validation.py +++ b/tests/validation/test_constraint_validation.py @@ -3,14 +3,14 @@ import pytest from pytest import param -from baybe.constraints.conditions import ThresholdCondition from baybe.constraints.continuous import ( ContinuousCardinalityConstraint, ContinuousLinearConstraint, ) from baybe.constraints.discrete import ( + DiscreteLinearConstraint, + DiscreteProductConstraint, DiscreteRepetitionConstraint, - DiscreteSumConstraint, ) @@ -76,9 +76,10 @@ def test_invalid_max_repetitions(kwargs, error, match): def test_invalid_coefficients(coefficients, match): """Invalid coefficients raise a ValueError.""" with pytest.raises(ValueError, match=match): - DiscreteSumConstraint( + DiscreteLinearConstraint( parameters=["A", "B", "C"], - condition=ThresholdCondition(threshold=1.0, operator="<="), + operator="<=", + rhs=1.0, coefficients=coefficients, ) with pytest.raises(ValueError, match=match): @@ -87,3 +88,32 @@ def test_invalid_coefficients(coefficients, match): operator="<=", coefficients=coefficients, ) + + +@pytest.mark.parametrize( + "constraint_cls", + [DiscreteLinearConstraint, DiscreteProductConstraint], + ids=["linear", "product"], +) +@pytest.mark.parametrize( + ("operator", "tolerance", "match"), + [ + param("=", float("nan"), "cannot be 'nan'", id="nan"), + param("=", float("inf"), "cannot be 'inf'", id="inf"), + param("=", -float("inf"), "cannot be 'inf'", id="neg-inf"), + param("=", 0.0, "must be positive", id="zero"), + param("=", -1.0, "must be positive", id="negative"), + param( + ">=", 0.1, "only valid with the following operators", id="wrong-operator" + ), + ], +) +def test_invalid_tolerance(constraint_cls, operator, tolerance, match): + """Invalid tolerances are rejected eagerly at construction.""" + with pytest.raises(ValueError, match=match): + constraint_cls( + parameters=["A", "B"], + operator=operator, + rhs=1.0, + tolerance=tolerance, + )