Policies#
What a policy is#
A policy routes an experiment state to an optimizer config. It has a name,
a version, and one method, route(state), which the ask boundary (the
layer between the strategy and the caller that enforces dedup and intents
and writes the receipt) calls once per ask. Because routing happens per ask rather than per experiment,
switching strategies mid-experiment is the normal case rather than a feature:
the policy reads the current state and picks whatever fits it. A policy must
be a pure function of the state, so that routed asks stay reproducible from
their receipts. The identity string name@version is recorded on every
policy-routed suggestion as policy.
Policies are the routing mechanism; strategies do the math. A policy never fits a model or draws a point – it chooses who will.
What the default policy does#
The open default policy is default@v1, a count-threshold router. Its rule
reads in one sentence: space-filling until max(4, 2 * dim) scored
(non-failed) observations exist, then the Gaussian-process (GP) rung –
gp_ei for one optimized metric, gp_nehvi for two or more. dim is the
number of parameters. The count is the whole decision: no score, bandit, or
comparison across strategies enters it, and the portfolio character of the
engine lives in the ladder beneath the routed rung. Failed observations are
not evidence and do not advance the bootstrap; neither do open suggestions.
Public benchmark claims use this policy only; the suite’s id for it is
tauopt/default (The benchmark suite).
Two behaviors you might expect from a policy live one level down, in the
strategies it routes to. Open suggestions are handled by constant-liar
conditioning inside gp_ei and ucb_cl (each open point is treated as if
it had returned the worst observed value; under gp_ei each
outcome-constraint component sits at its threshold) and by pending-point
integration
inside gp_nehvi. And the policy never reads observation_budget: the
default routing ignores it, and its one consumer is the optional
queue_selection config block, which adopts it as its horizon when the
block names none – an injection both drivers perform before the ask. With no
budget, an unset horizon means the engine’s own
assumption of 15 * dim (dim is the parameter count), recorded as
horizon_source assumed_15d.
from tauopt import ExperimentState, MetricSpec, OptimizerConfig, Parameter, Space, ask
from tauopt.engine import Objective, ObservationRecord, derive_objective
from tauopt.engine.policy import DefaultPolicyV1, policy_id
policy = DefaultPolicyV1()
space = Space(parameters=(
Parameter(name="x", type="double", bounds=(0.0, 1.0)),
Parameter(name="y", type="double", bounds=(0.0, 1.0)),
))
state = ExperimentState(space=space, objective=Objective(metric="score", goal="max"))
print(policy_id(policy), "bootstrap until", max(4, 2 * space.dim), "scored observations")
for i in range(3):
state = state.with_observation(ObservationRecord(assignments={"x": i / 10, "y": 0.5}, value=float(i)))
state = state.with_observation(ObservationRecord(assignments={"x": 0.9, "y": 0.9}, failed=True))
print(len(state.observations), "observations ->", policy.route(state).strategy)
state = state.with_observation(ObservationRecord(assignments={"x": 0.7, "y": 0.1}, value=3.0))
print(len(state.observations), "observations ->", policy.route(state).strategy)
metrics = (MetricSpec(name="a", goal="max"), MetricSpec(name="b", goal="min"))
moo = ExperimentState(space=space, objective=derive_objective(metrics), metrics=metrics)
for i in range(4):
moo = moo.with_observation(ObservationRecord(assignments={"x": i / 10, "y": 0.5}, values={"a": float(i), "b": 1.0}))
print("multimetric ->", policy.route(moo).strategy)
try:
ask(state, config=OptimizerConfig(strategy="auto", params={"num_restarts": 4}))
except ValueError as e:
print("refused:", e)
fresh = ExperimentState(space=space, objective=Objective(metric="score", goal="max"))
r = ask(fresh, config=OptimizerConfig(strategy="my_custom"))
print(r.provenance.strategy, r.provenance.fallback_from, r.provenance.policy, r.provenance.dedup.ladder_hops)
default@v1 bootstrap until 4 scored observations
4 observations -> sobol
5 observations -> gp_ei
multimetric -> gp_nehvi
refused: strategy 'auto' takes no params (top-level seed / base_noise / center_in_bootstrap / dedup / duplicate_intents / queue_selection / categorical_mode / timebox_mode only)
sobol my_custom None ('gp_ei', 'ucb_cl', 'sobol')
The fourth observation is a failure, so four rows still route to sobol;
the fifth row is the fourth piece of evidence and flips the route.
When an explicit config bypasses the policy#
Passing a config with a named strategy wins outright: the policy is never
consulted and the receipt’s policy field is None. This is the public
config layer, and it is deliberate – a hosted service may run a cleverer
closed router, but you can always pin exactly what runs. The cost is that
you own the routing; a GP pinned before any observation exists descends the
fallback ladder on every ask until one does.
The version tag on the default policy rolls only when its behavior moves.
The v1 tag marks the switch to deduplication as the engine-wide default,
which changed what default asks emit on trajectories that would otherwise
collide; the routing rule itself is unchanged from v0.
The auto strategy#
strategy: "auto" is the seedable policy path. It routes exactly as passing
no config, while carrying the top-level fields – seed,
center_in_bootstrap, dedup, duplicate_intents, queue_selection,
categorical_mode, timebox_mode, and a non-default base_noise – into
whichever rung the policy picks and down the fallback ladder. It refuses
params, since params belong to a specific strategy and no routed rung
would own them. Use it when you want policy routing plus a pinned seed or
a pinned envelope posture.
Unknown names and custom strategies#
Strategies resolve through a registry. registry.register(instance) installs
a custom strategy under its name, and registered instances take precedence
over the built-ins, which is how you swap a built-in for an experimental
variant locally. A name that is neither built-in nor registered is not
available, and the ask boundary treats it like any unavailable rung: it
falls through the ladder and the receipt reports the name you asked for in
fallback_from, as the my_custom line above shows. No error is raised for
a misspelled strategy name, so read fallback_from when a pinned config
seems to be ignored. The full config surface is in the
strategies and config reference.
Do it: Pin a strategy and seed.