Source code for phenotypic.tune.strategy._config

"""Serializable strategy configs; each builds its live SearchStrategy.

These are a closed set in Phase 1 (grid/random) → a discriminated union.
Phase 2 adds ``OptunaConfig``; the polymorphic-field path (engine-architecture
§6) lets the open Scorer/Strategy sets extend, but the in-spec config field uses
this union for the built-in kinds.
"""
from __future__ import annotations

import os
from abc import abstractmethod
from typing import Annotated, Any, Literal, Optional, TypeAlias, Union, get_args

from pydantic import BaseModel, ConfigDict, Field

from .._search_space import SearchSpace
from ._grid import GridStrategy
from ._protocol import SearchStrategy
from ._random import RandomStrategy

#: Closed set of strategy-config discriminator tags (reused by the union).
StrategyKind = Literal["grid", "random"]

#: The Optuna sampler roster — a closed set (never a bare ``str``). ``"tpe"`` is
#: the default (handles our mixed categorical/conditional space); ``"cmaes"`` is
#: the continuous-dominant / post-screening focused-round sampler; ``"gp"`` the
#: low-dim expensive-eval Gaussian-process sampler; ``"nsga2"`` the
#: multi-objective genetic sampler (auto-selected for a multi-objective study).
SamplerKind: TypeAlias = Literal["tpe", "cmaes", "gp", "nsga2"]

#: The :data:`SamplerKind` roster as a runtime ``frozenset`` — the single source
#: the CLI's "is this strategy name an Optuna sampler?" test reads (so the roster
#: lives once, beside the ``Literal`` it mirrors).
OPTUNA_SAMPLERS: frozenset[str] = frozenset(get_args(SamplerKind))

#: The full ``--strategy`` choice tuple: the homegrown ``grid``/``random`` plus
#: every Optuna sampler, derived from :data:`SamplerKind` so the CLI choices and
#: the sampler roster cannot drift apart.
STRATEGY_CHOICES: tuple[str, ...] = ("grid", "random", *get_args(SamplerKind))

#: Environment variable naming the Optuna storage URL when ``OptunaConfig`` is
#: built with ``storage_url=None`` (e.g. a SLURM array exporting a shared
#: Postgres/SQLite URL to every worker). Resolved inside ``build`` only — never
#: at construction — so the lazy-import boundary holds.
PHENOTYPIC_TUNE_STORAGE_URL_ENV: str = "PHENOTYPIC_TUNE_STORAGE_URL"


[docs] class StrategyConfig(BaseModel): """Abstract, serializable config that builds its live ``SearchStrategy``. A frozen value-model; concrete subclasses (``GridConfig`` / ``RandomConfig``) carry a ``kind`` discriminator and implement ``build``. ``StrategyConfig`` itself cannot be instantiated (it has an abstract method). Args: seed: The RNG seed forwarded to seeded strategies; defaults to ``0``. """ model_config = ConfigDict(frozen=True, extra="forbid") seed: int = 0
[docs] @abstractmethod def build( self, space: SearchSpace, store: Optional[Any], *, directions: Optional[list[str]] = None, ) -> SearchStrategy: """Construct the live strategy for ``space``. Args: space: The search space the built strategy operates over. store: The study store (Phase 1d's ``StudyStore``); accepted for a uniform factory signature but unused by the zero-dependency grid/random strategies. directions: Per-objective Optuna ``directions`` for a multi-objective run (``["minimize"] * n``), inferred from the scorer by the engine. ``None`` → single-objective. Only the Optuna backend honors it; grid/random ignore it (4.8 rejects pairing them with a multi-objective scorer at validation, so they never receive it). """ ...
[docs] class GridConfig(StrategyConfig): """Builds a ``GridStrategy`` (exhaustive conditional-Cartesian enumeration). Args: kind: The discriminator tag; always ``"grid"``. """ kind: Literal["grid"] = "grid"
[docs] def build( self, space: SearchSpace, store: Optional[Any], *, directions: Optional[list[str]] = None, ) -> SearchStrategy: # Grid enumeration is single-objective; ``directions`` is ignored (a # multi-objective scorer with grid is rejected at validation, 4.8). return GridStrategy(space)
[docs] class RandomConfig(StrategyConfig): """Builds a ``RandomStrategy`` (seeded random sampling). Args: kind: The discriminator tag; always ``"random"``. n_trials: The number of configurations to sample before exhaustion. """ kind: Literal["random"] = "random" n_trials: int
[docs] def build( self, space: SearchSpace, store: Optional[Any], *, directions: Optional[list[str]] = None, ) -> SearchStrategy: # Random sampling is single-objective; ``directions`` is ignored (a # multi-objective scorer with random is rejected at validation, 4.8). return RandomStrategy(space, n_trials=self.n_trials, seed=self.seed)
[docs] class OptunaConfig(StrategyConfig): """Builds an ``OptunaStrategy`` (TPE/CMA-ES/GP/NSGA-II + ASHA pruning). The Phase-2 backend config. Construction and serialization stay Optuna-free; only :meth:`build` resolves the optional ``tune`` extra (via ``_require_optuna``). It rides the ``TuningSpec.strategy`` polymorphic field (not the closed ``StrategyConfigUnion``), so it round-trips through the class registry like any ``StrategyConfig`` subclass. Args: kind: The discriminator tag; always ``"optuna"``. sampler: The Optuna sampler (a closed :data:`SamplerKind` set); defaults to ``"tpe"``. A multi-objective study auto-selects NSGA-II at build time regardless of this value (Phase 4 wires the scorer). n_trials: The completed+pruned trial budget before exhaustion. prune: Whether to enable ASHA pruning (opt-in; the explore round runs unpruned). Defaults to ``False``. seed: The sampler seed for reproducibility; defaults to ``0``. storage_url: The Optuna storage URL (SQLite/Postgres). ``None`` resolves from ``$PHENOTYPIC_TUNE_STORAGE_URL`` at build time; if that is also unset the strategy uses an in-memory study. """ kind: Literal["optuna"] = "optuna" sampler: SamplerKind = "tpe" n_trials: int prune: bool = False storage_url: Optional[str] = None
[docs] def build( self, space: SearchSpace, store: Optional[Any], *, directions: Optional[list[str]] = None, ) -> SearchStrategy: """Construct the live ``OptunaStrategy`` (resolves the ``tune`` extra). ``import optuna`` is deferred to ``OptunaStrategy``'s body; this method calls ``_require_optuna`` first to raise an actionable error when the extra is missing. The storage URL falls back to ``$PHENOTYPIC_TUNE_STORAGE_URL`` when ``storage_url is None``. When ``directions`` is multi-objective the strategy auto-selects **NSGA-II** (the multi-objective sampler) regardless of :attr:`sampler` and disables pruning (Optuna pruners are single-objective; optuna-integration §9). Args: space: The search space to materialize each trial from. store: The study store (the Optuna study owns persistence; passed for the uniform factory signature). directions: Per-objective ``["minimize"] * n`` for a multi-objective run, inferred from the scorer; ``None`` → single-objective. """ from ._optuna import OptunaStrategy from ._optuna_support import _require_optuna _require_optuna() # Bind the strategy to the STORE's shared study so its native ask/tell is # the single persisted record (sampler resumes in place, the SLURM fleet # drains one budget, no phantom auto-named study). An Optuna store exposes # ``study_name`` + ``storage_url``; a non-Optuna store (the screening # rounds' journal) exposes neither, so the strategy falls back to its own # study from the explicit URL / the ``$PHENOTYPIC_TUNE_STORAGE_URL`` env. study_name = getattr(store, "study_name", None) storage_url = getattr(store, "storage_url", None) or self.storage_url if storage_url is None: storage_url = os.environ.get(PHENOTYPIC_TUNE_STORAGE_URL_ENV) return OptunaStrategy( space, sampler=self.sampler, n_trials=self.n_trials, prune=self.prune, seed=self.seed, storage_url=storage_url, store=store, study_name=study_name, directions=directions, )
#: Discriminated union of the built-in (Phase 1) strategy configs. ``OptunaConfig`` #: is intentionally **not** a member: it rides the ``TuningSpec.strategy`` #: polymorphic field (registry round-trip), not this closed union. StrategyConfigUnion = Annotated[ Union[GridConfig, RandomConfig], Field(discriminator="kind") ]