diff --git a/CHANGELOG.md b/CHANGELOG.md index 859cc31c..7c754b8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,12 @@ format. Stability guarantees for the public surface are documented in the ### Added +- `Problem` now accepts a typed scalar callable directly through `objective=` + and normalizes it at construction into the existing `Objective` and + evaluation-protocol path. Existing direction, failure, accounting, + diagnostics, and evaluator behavior is unchanged; `Problem.name` remains the + human-facing label. Picklable functions defined at an importable module level + are the conservative choice for process and MPI portability. - Added `CSAOptimizer.configuration_manifest(...)` and the supported `CSAConfigurationManifest`, `CSAComponentDescriptor`, and `CSAConfigurationResolutionError` artifacts. The versioned manifest records diff --git a/README.md b/README.md index 241999cf..51f5ce5e 100644 --- a/README.md +++ b/README.md @@ -13,24 +13,20 @@ stability policy, and [docs](docs/index.md) for the user-facing guide. ## Quickstart ```python -from typing_extensions import override - -from variopt import IntegerSpace, Objective, OptimizationDirection, Problem, Study +from variopt import IntegerSpace, OptimizationDirection, Problem, Study from variopt.algorithms.population import CSAOptimizer from variopt.evaluators import SequentialEvaluator -class SquareObjective(Objective[int]): - @override - def evaluate(self, candidate: int) -> float: - return float(candidate * candidate) +def square(candidate: int) -> float: + return float(candidate * candidate) space = IntegerSpace(-10, 10) problem = Problem( space=space, - objective=SquareObjective(), + objective=square, direction=OptimizationDirection.MINIMIZE, ) @@ -58,6 +54,13 @@ non-scalar [`EvaluationProtocol`](docs/reference/api/variopt.md), use `Study.run(...)` instead to get a generic [`RunReport`](docs/reference/api/artifacts.md). +`Problem` accepts either a typed scalar callable or an explicit +[`Objective`](docs/reference/api/variopt.md) implementation. Prefer a +picklable, importable module-level function when the problem may cross a +process or MPI boundary; lambdas, closures, bound methods, and stateful +callable objects are not guaranteed to be portable across every evaluator +backend. + ## Evaluator Backends For batch-parallel local execution, use the joblib-backed evaluator included diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index cfc9fa07..ead1e06a 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -3,22 +3,19 @@ This is the smallest practical scalar optimization example. ```python -from typing_extensions import override - -from variopt import IntegerSpace, Objective, Problem, Study +from variopt import IntegerSpace, Problem, Study from variopt.algorithms.population import CSAOptimizer from variopt.evaluators import SequentialEvaluator -class SquareObjective(Objective[int]): - @override - def evaluate(self, candidate: int) -> float: - return float(candidate * candidate) +def square(candidate: int) -> float: + return float(candidate * candidate) problem = Problem( space=IntegerSpace(-10, 10), - objective=SquareObjective(), + objective=square, + name="square", ) optimizer = CSAOptimizer.from_space_defaults( @@ -59,7 +56,8 @@ evaluation sequence. ## What This Example Uses - [`IntegerSpace`][variopt.IntegerSpace] defines the search domain -- [`Objective`][variopt.Objective] maps a candidate to a scalar value +- [`Problem`][variopt.Problem] normalizes the typed `square` callable into its + canonical scalar objective contract - [`CSAOptimizer.from_space_defaults(...)`][variopt.algorithms.population.CSAOptimizer.from_space_defaults] derives sampler, diversity metric, and perturbation schedule from the space - [`SequentialEvaluator`][variopt.evaluators.SequentialEvaluator] evaluates @@ -72,6 +70,13 @@ evaluation sequence. higher evaluation cost. `8` is chosen here for a small illustrative run; pick the size based on your evaluation budget, not from this example. +An explicit [`Objective`][variopt.Objective] subclass remains useful when a +named reusable class better represents the evaluation rule. For process and +MPI evaluators, a picklable function defined at an importable module level is +the conservative callable choice. Lambdas, closures, bound methods, and +stateful callable objects may work with particular serializers but are not +guaranteed to be portable across every backend. + ## Next Steps - fuller walkthrough: diff --git a/docs/guides/choose-an-evaluator.md b/docs/guides/choose-an-evaluator.md index 2b8b3512..ba917b26 100644 --- a/docs/guides/choose-an-evaluator.md +++ b/docs/guides/choose-an-evaluator.md @@ -28,6 +28,13 @@ Evaluator choice should not silently change the meaning of the optimizer. `exact_async`, and `stale_async` as semantic contracts rather than backend labels. +When [`Problem`][variopt.Problem] receives a scalar callable as its objective, +prefer a picklable function defined at an importable module level if evaluation +may cross a process or MPI boundary. Sequential and threading execution can use +process-local callables, and Joblib's loky backend uses `cloudpickle`, but those +capabilities do not make lambdas, closures, bound methods, or stateful callable +objects universally portable across evaluator backends. + ## Practical Mapping - want the simplest one-proposal path: diff --git a/src/variopt/problem.py b/src/variopt/problem.py index 72dcde8b..fd3d8362 100644 --- a/src/variopt/problem.py +++ b/src/variopt/problem.py @@ -1,5 +1,6 @@ """Problem definitions.""" +from collections.abc import Callable from dataclasses import dataclass, field from typing import Generic @@ -30,6 +31,41 @@ InteractionProblemRecordT = TypeVar("InteractionProblemRecordT") +class _CallableObjective(Objective[CandidateT], Generic[CandidateT]): + """Scalar objective view over one typed candidate callable.""" + + def __init__(self, function: Callable[[CandidateT], float]) -> None: + self._function = function + + @override + def __eq__(self, other: object) -> bool: + """Return whether two views wrap the same callable object.""" + return ( + isinstance(other, _CallableObjective) and self._function is other._function + ) + + @override + def __hash__(self) -> int: + """Return an identity hash aligned with callable-view equality.""" + return hash((type(self), id(self._function))) + + @override + def evaluate(self, candidate: CandidateT) -> float: + """Return the wrapped callable's raw scalar value. + + Parameters + ---------- + candidate : CandidateT + Canonical candidate to evaluate. + + Returns + ------- + float + Raw objective value returned by the wrapped callable. + """ + return self._function(candidate) + + @dataclass(frozen=True, slots=True) class _ProtocolObjectiveCompatibilityView( FrozenGenericSlotsCompat, Objective[CandidateT], Generic[CandidateT] @@ -121,9 +157,9 @@ class Problem( ---------- space : SearchSpace[BoundaryT, CandidateT] Canonical search-space definition for valid candidates. - objective : Objective[CandidateT] | None, optional - Optional scalar objective compatibility view. Provide this for the - simplest scalar problem definitions. + objective : Objective[CandidateT] | Callable[[CandidateT], float] | None, optional + Optional scalar objective or typed candidate callable. Callable inputs + are normalized immediately into the canonical objective contract. evaluation_protocol : EvaluationProtocol[CandidateT, ProblemPayloadT] | ObservationEvaluationProtocol[CandidateT] | None, optional Optional canonical request-aligned evaluation protocol. This may be direction-free or a scalar observation protocol that ``Problem`` will @@ -138,7 +174,9 @@ class Problem( Notes ----- Exactly one of ``objective`` or ``evaluation_protocol`` must be provided. - Internally, ``Problem`` stores the direction-free + Callable objectives are accepted only at construction and exposed + thereafter through the canonical :class:`~variopt.objective.Objective` + view. Internally, ``Problem`` stores the direction-free :class:`~variopt.objective.EvaluationProtocol` contract and exposes ``objective`` only as a compatibility view when a scalar interpretation is available. The canonical evaluation protocol must emit request-free @@ -159,7 +197,9 @@ def __init__( self, *, space: SearchSpace[BoundaryT, CandidateT], - objective: Objective[CandidateT] | None = None, + objective: ( + Objective[CandidateT] | Callable[[CandidateT], float] | None + ) = None, evaluation_protocol: ( EvaluationProtocol[CandidateT, ProblemPayloadT] | ObservationEvaluationProtocol[CandidateT] @@ -175,8 +215,9 @@ def __init__( space : SearchSpace[BoundaryT, CandidateT] Search-space definition that owns candidate validity and sampling semantics. - objective : Objective[CandidateT] | None, optional - Optional scalar objective compatibility view. + objective : Objective[CandidateT] | Callable[[CandidateT], float] | None, optional + Optional scalar objective or typed candidate callable. Callable + inputs are normalized into an :class:`Objective`. evaluation_protocol : EvaluationProtocol[CandidateT, ProblemPayloadT] | ObservationEvaluationProtocol[CandidateT] | None, optional Optional canonical payload-returning evaluation protocol or scalar observation protocol. @@ -188,6 +229,10 @@ def __init__( Raises ------ + TypeError + If ``objective`` is neither an :class:`Objective` nor a callable, + or if ``direction`` is not an + :class:`~variopt.direction.OptimizationDirection`. ValueError If neither or both of ``objective`` and ``evaluation_protocol`` are provided. @@ -198,7 +243,22 @@ def __init__( msg = "exactly one of objective or evaluation_protocol must be provided" raise ValueError(msg) - protocol = objective if objective is not None else evaluation_protocol + normalized_objective: Objective[CandidateT] | None + if objective is None: + normalized_objective = None + elif isinstance(objective, Objective): + normalized_objective = objective + elif callable(objective): + normalized_objective = _CallableObjective(objective) + else: + msg = "objective must be an Objective, callable, or None" + raise TypeError(msg) + + protocol = ( + normalized_objective + if normalized_objective is not None + else evaluation_protocol + ) if protocol is None: msg = "evaluation_protocol normalization failed" raise RuntimeError(msg) @@ -217,12 +277,12 @@ def __init__( | _ObservationProtocolEvaluationProtocolAdapter[CandidateT] ) objective_compat: Objective[CandidateT] | None - if objective is not None: + if normalized_objective is not None: canonical_protocol = _ObservationProtocolEvaluationProtocolAdapter( - observation_evaluation_protocol=objective, + observation_evaluation_protocol=normalized_objective, direction=canonical_direction, ) - objective_compat = objective + objective_compat = normalized_objective elif isinstance(protocol, Objective): canonical_protocol = _ObservationProtocolEvaluationProtocolAdapter( observation_evaluation_protocol=protocol, @@ -290,7 +350,8 @@ def objective(self) -> Objective[CandidateT]: ----- Prefer :attr:`evaluation_protocol` in canonical internal code. This property is for boundary convenience when a scalar objective view is - meaningful. + meaningful. Callable constructor inputs are returned through their + normalized :class:`Objective` view rather than as the original callable. """ if self._objective_compat is None: msg = "problem does not expose a scalar Objective compatibility view" @@ -299,14 +360,15 @@ def objective(self) -> Objective[CandidateT]: @property def direct_objective(self) -> Objective[CandidateT] | None: - """Return the direct scalar objective configured on this problem, if any. + """Return the direct scalar objective view for this problem, if any. Returns ------- Objective[CandidateT] | None - The scalar objective supplied directly at construction time. Returns - ``None`` for non-scalar protocols and for scalar observation - protocols adapted through request-aware compatibility views. + The supplied scalar objective or the canonical objective view + normalized from a callable. Returns ``None`` for non-scalar + protocols and for scalar observation protocols adapted through + request-aware compatibility views. Notes ----- diff --git a/tests/core/test_problem_contracts.py b/tests/core/test_problem_contracts.py index c2a3b6b8..85465434 100644 --- a/tests/core/test_problem_contracts.py +++ b/tests/core/test_problem_contracts.py @@ -1,6 +1,6 @@ import dataclasses import pickle -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import TypeVar, cast import pytest @@ -13,6 +13,7 @@ MatchupSpec, ShiftedObservationProtocol, SquareObjective, + square_objective_value, ) from variopt import ( EvaluationOutcome, @@ -110,6 +111,29 @@ def evaluate(self, candidate: tuple[SpaceCandidateValue, ...]) -> float: return float(len(candidate)) +class CountingSquareCallable: + """Count calls while returning a scalar square.""" + + def __init__(self) -> None: + self.call_count = 0 + + def __call__(self, candidate: int) -> float: + self.call_count += 1 + return float(candidate * candidate) + + +class EqualityHostileSquareCallable: + """Scalar callable whose value equality must remain outside normalization.""" + + def __call__(self, candidate: int) -> float: + return float(candidate * candidate) + + @override + def __eq__(self, other: object) -> bool: + _ = other + raise AssertionError("callable equality must not define Problem identity") + + class ProblemContractsTests: """Coverage for problem construction and interaction contract validation.""" @@ -157,6 +181,73 @@ def test_problem_pickle_round_trips_without_runtime_generic_metadata(self) -> No assert restored.name == "square" assert restored.objective.evaluate(4) == 16.0 + def test_problem_preserves_direct_objective_identity(self) -> None: + objective = SquareObjective() + problem = Problem( + space=IntegerSpace(low=0, high=10), + objective=objective, + ) + + assert problem.objective is objective + assert problem.direct_objective is objective + + def test_callable_problem_equality_uses_source_identity(self) -> None: + objective = EqualityHostileSquareCallable() + first = Problem( + space=IntegerSpace(low=0, high=10), + objective=objective, + ) + second = Problem( + space=IntegerSpace(low=0, high=10), + objective=objective, + ) + + assert first == second + assert hash(first) == hash(second) + + @pytest.mark.parametrize( + ("direction", "expected_score"), + ( + (OptimizationDirection.MINIMIZE, 16.0), + (OptimizationDirection.MAXIMIZE, -16.0), + ), + ) + def test_problem_normalizes_callable_objective_once( + self, + direction: OptimizationDirection, + expected_score: float, + ) -> None: + objective = CountingSquareCallable() + problem = Problem( + space=IntegerSpace(low=0, high=10), + objective=objective, + direction=direction, + name="square", + ) + + payload = problem.evaluation_protocol.evaluate_proposal( + Proposal(candidate=4), + ) + + assert isinstance(problem.objective, Objective) + assert problem.direct_objective is problem.objective + assert problem.name == "square" + assert payload.value == 16.0 + assert payload.score == expected_score + assert objective.call_count == 1 + + def test_callable_objective_problem_pickle_round_trips(self) -> None: + problem = Problem( + space=IntegerSpace(low=0, high=10), + objective=square_objective_value, + name="square", + ) + + restored = pickle_round_trip(problem) + + assert restored.name == "square" + assert restored.objective.evaluate(4) == 16.0 + def test_observation_protocol_problem_pickle_round_trips(self) -> None: problem: Problem[int, int, ObservationPayload] = Problem( space=IntegerSpace(low=0, high=10), @@ -467,6 +558,18 @@ def test_problem_rejects_both_objective_and_protocol(self) -> None: evaluation_protocol=ShiftedObservationProtocol(), ) + def test_problem_rejects_invalid_objective_type(self) -> None: + invalid_objective = cast(Callable[[int], float], 42) + + with pytest.raises( + TypeError, + match="objective must be an Objective, callable, or None", + ): + _ = Problem( + space=IntegerSpace(low=0, high=10), + objective=invalid_objective, + ) + def test_problem_rejects_empty_name(self) -> None: with pytest.raises(ValueError): _ = Problem( diff --git a/tests/core/test_problem_execution.py b/tests/core/test_problem_execution.py index a2b88993..7a75772e 100644 --- a/tests/core/test_problem_execution.py +++ b/tests/core/test_problem_execution.py @@ -13,6 +13,7 @@ NaNObjective, ShiftedObservationProtocol, SquareObjective, + fail_on_four_objective_value, ) from variopt import ( EvaluationProtocol, @@ -298,6 +299,21 @@ def test_payload_pipeline_attempt_records_user_exception(self) -> None: assert attempts.failures[0].request is request assert attempts.failures[0].exception.exception_type == "builtins.ValueError" + def test_callable_objective_failure_uses_the_standard_attempt_path(self) -> None: + problem = Problem( + space=IntegerSpace(low=0, high=10), + objective=fail_on_four_objective_value, + ) + request = EvaluationRequest(proposal=Proposal(candidate=4, proposal_id="p-1")) + + attempts = evaluate_request_attempt(problem=problem, request=request) + + assert attempts.successes == () + assert attempts.failure_indices == (0,) + assert attempts.failures[0].request is request + assert attempts.failures[0].exception.exception_type == "builtins.ValueError" + assert attempts.failures[0].exception.message == "boom" + def test_payload_success_helper_keeps_user_exception_hard(self) -> None: problem = Problem( space=IntegerSpace(low=0, high=10), diff --git a/tests/evaluators/test_joblib_worker_session.py b/tests/evaluators/test_joblib_worker_session.py index a7566cf0..778ff637 100644 --- a/tests/evaluators/test_joblib_worker_session.py +++ b/tests/evaluators/test_joblib_worker_session.py @@ -18,6 +18,7 @@ import pytest from typing_extensions import Never, override +from tests.problem_artifact_support import square_objective_value from variopt import ( EvaluationAttemptBatch, EvaluationRequest, @@ -201,6 +202,31 @@ def successful_values( class JoblibWorkerSessionTests: """Exercise lifecycle, isolation, and failure boundaries.""" + @pytest.mark.parametrize( + "problem_transport", + ("per_request", "worker_session"), + ) + def test_loky_transports_callable_objective( + self, + problem_transport: JoblibProblemTransportMode, + ) -> None: + problem: Problem[int, int, ObservationPayload] = Problem( + space=IntegerSpace(low=0, high=10), + objective=square_objective_value, + ) + evaluator = JoblibEvaluator[int, int, ObservationPayload]( + backend="loky", + n_jobs=2, + problem_transport=problem_transport, + ) + + attempts = evaluator.evaluate_attempts( + problem, + make_requests(2, 3), + ) + + assert successful_values(attempts) == (4.0, 9.0) + def test_default_transport_remains_per_request(self) -> None: evaluator = JoblibEvaluator[int, int, ObservationPayload]() diff --git a/tests/problem_artifact_support.py b/tests/problem_artifact_support.py index ba28fb46..40c25cfc 100644 --- a/tests/problem_artifact_support.py +++ b/tests/problem_artifact_support.py @@ -21,6 +21,19 @@ ) +def square_objective_value(candidate: int) -> float: + """Return the square of one integer candidate.""" + return float(candidate * candidate) + + +def fail_on_four_objective_value(candidate: int) -> float: + """Raise for candidate four and otherwise return the candidate value.""" + if candidate == 4: + msg = "boom" + raise ValueError(msg) + return float(candidate) + + class SquareObjective(Objective[int]): """Minimal objective used to exercise problem and artifact contracts.""" diff --git a/tests/study/test_study.py b/tests/study/test_study.py index b27ce804..2c06bb34 100644 --- a/tests/study/test_study.py +++ b/tests/study/test_study.py @@ -9,6 +9,7 @@ from typing_extensions import override import variopt.study.assimilation as study_assimilation +from tests.problem_artifact_support import square_objective_value from tests.study_support import ( BatchQueueOptimizer, BatchQueueOptimizerState, @@ -80,6 +81,7 @@ ) from variopt.study.common import CheckpointSafeRunSnapshot, build_evaluation_requests from variopt.study.execution import ( + _supports_direct_scalar_sequential_path, evaluate_attempts_sync, materialize_scalar_run_result, ) @@ -1926,6 +1928,33 @@ def test_optimize_fast_path_preserves_budget_boundaries(self) -> None: execution_model=SEQUENTIAL_EXECUTION_MODEL, ) + def test_callable_objective_remains_eligible_for_the_scalar_fast_path( + self, + ) -> None: + problem = Problem( + space=IntegerSpace(low=0, high=10), + objective=square_objective_value, + ) + optimizer = BatchQueueOptimizer( + proposal_batches=[(Proposal(candidate=4, proposal_id="p-1"),)], + ) + evaluator = SequentialEvaluator[int, int]() + study = Study(problem=problem, run_method=optimizer, evaluator=evaluator) + + result, _ = study.optimize( + max_evaluations=1, + execution_model=SEQUENTIAL_EXECUTION_MODEL, + ) + + assert result.evaluation_count == 1 + assert result.best_observation is not None + assert result.best_observation.candidate == 4 + assert result.best_observation.value == 16.0 + assert _supports_direct_scalar_sequential_path( + study, + execution_model=SEQUENTIAL_EXECUTION_MODEL, + ) + def test_run_returns_terminal_run_report_for_scalar_observations(self) -> None: problem = Problem( space=IntegerSpace(low=0, high=10),