Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions src/trade_study/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
43 changes: 43 additions & 0 deletions tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading