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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions docs/guides/choose-an-evaluator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
94 changes: 78 additions & 16 deletions src/variopt/problem.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Problem definitions."""

from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Generic

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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
-----
Expand Down
Loading