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.