diff --git a/CHANGELOG.md b/CHANGELOG.md index 91229c8..91f60e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file. ### Fixed - Queued, retried and imported adaptive evaluations now join NSGA-II generations and process constraints when completed; interrupted imports resume idempotently. The adaptive extra now requires Optuna >=4.5 for its public generation API (#164). +- Strict adaptive `<`/`>` constraints now treat exact threshold equality as infeasible, matching result-table feasibility without rejecting adjacent feasible values (#167). ## [0.3.0] — 2026-10-02 diff --git a/src/trade_study/session.py b/src/trade_study/session.py index 3bdc2d3..1094721 100644 --- a/src/trade_study/session.py +++ b/src/trade_study/session.py @@ -48,15 +48,25 @@ def _constraint_value(constraint: Constraint, value: float) -> float: ValueError: If the operator has no continuous violation measure. """ if constraint.op in {"<=", "<"}: - return value - constraint.threshold + return _open_violation( + value - constraint.threshold, strict=constraint.op == "<" + ) if constraint.op in {">=", ">"}: - return constraint.threshold - value + return _open_violation( + constraint.threshold - value, strict=constraint.op == ">" + ) if constraint.op == "==": return abs(value - constraint.threshold) msg = f"Constraint operator {constraint.op!r} is not supported in adaptive search" raise ValueError(msg) +def _open_violation(violation: float, *, strict: bool) -> float: + if strict and violation == 0: + return float(np.nextafter(0.0, np.inf)) + return violation + + def _summarize(values: float | Sequence[float]) -> tuple[float, float, int]: """Return the mean, Monte Carlo standard error and count of replicates.""" array = np.atleast_1d(np.asarray(values, dtype=float)) diff --git a/tests/test_session.py b/tests/test_session.py index d5752b8..81178bf 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -213,3 +213,46 @@ def test_retry_bounds_and_invalid_transitions() -> None: snapshot.metadata["failure_reason"] = "changed" assert session.trials()[0].config == config assert session.trials()[0].metadata["failure_reason"] == "simulator error" + + +@pytest.mark.parametrize("op", ["<", ">", "<=", ">="]) +def test_constraint_boundaries_match_stored_sampler_feasibility( + tmp_path: Path, op: str +) -> None: + threshold = 0.5 + values = [ + np.nextafter(threshold, -np.inf), + threshold, + np.nextafter(threshold, np.inf), + ] + constraint = Constraint("boundary", "cost", op, threshold) + path = tmp_path / "boundaries.journal" + factors = [Factor("x", FactorType.CONTINUOUS, bounds=(0, 1))] + observables = [Observable("cost", Direction.MINIMIZE)] + session = AdaptiveSession( + factors, + observables, + constraints=[constraint], + path=path, + revision="v1", + ) + for value in values: + session.enqueue({"x": float(value)}) + ((trial_id, _config),) = session.ask() + session.tell(trial_id, {"cost": float(value)}) + results = session.results() + expected = results.feasible([constraint]).tolist() + stored = optuna.load_study( + study_name="trade-study-adaptive", + storage=optuna.storages.JournalStorage( + optuna.storages.journal.JournalFileBackend(str(path)) + ), + ) + assert [t.system_attrs["constraints"][0] <= 0 for t in stored.trials] == expected + assert [m["constraints"][0] <= 0 for m in results.metadata] == expected + assert expected[1] is (op in {"<=", ">="}) + imported = AdaptiveSession( + factors, observables, constraints=[constraint], revision="v1" + ) + imported.warm_start(results) + assert [m["constraints"][0] <= 0 for m in imported.results().metadata] == expected