Write an objective or an executor¶
The sampler seam decides what a trial tries and the scheduler seam decides what happens to it. This one decides how it gets evaluated: on a thread in this process, in a child process, or somewhere neither of those describes — a job queue, a cluster scheduler, a rig with hardware in the loop.
There is one sentence to take away before the detail. An executor is not a new
loop; it is an Objective. The subprocess executor that ships with atune is an
ordinary implementation of the trait, and its run method is a thin wrapper over
the study's own optimize_with. That is why it inherits parallelism, retries,
budgets, panic isolation and resume rather than reimplementing them, and it is the
shape to copy.
The objective seam¶
Objective has one method: given a trial context, return an Outcome — one value
per direction the study declared, so a single-objective study simply has one. It
is Send + Sync, because trials may run on several threads, and there is a
blanket implementation for any matching closure, which is how most objectives are
written — the Ok(…into()) below is an f64 becoming a one-value Outcome:
let study = Study::builder()
.parallelism(THREADS)
.budget(Budget::trials(TRIALS))
.create(StudyConfig::new(STUDY).with_seed(SEED))?;
study.optimize(|ctx| {
let x = ctx.suggest_f64("x", -5.12..=5.12, Scale::Linear)?;
let y = ctx.suggest_f64("y", -5.12..=5.12, Scale::Linear)?;
Ok(rastrigin(&[x, y]).into())
})?;
study = atune.create_study(
direction="minimize",
seed=SEED,
name=STUDY,
)
study.optimize(objective, n_trials=TRIALS, n_jobs=JOBS)
Two entry points take it. optimize is bound by a function trait so a closure
needs no type annotation; optimize_with takes &dyn Objective and is the one to
use for a hand-written implementation, a boxed trait object, or an objective
chosen at run time.
What the context hands you¶
| Through the context | For |
|---|---|
suggest_* |
Asking for a parameter by name and distribution — the define-by-run path. Resolved through a five-step priority chain: replay, enqueued, single-valued, the sampler's joint draw, then an independent draw. |
suggest_declared, suggest_open_* |
The study-owned spellings: suggest_declared draws a parameter whose range the study already declared (no range restated at the call site), and suggest_open_f64/suggest_open_int declare a growing range in place (open search spaces). On a parameter governed by an open policy, a caller-ranged suggest_f64 is refused — one range, one home. |
report, should_stop |
Publishing an intermediate value at a step, and receiving the scheduler's decision back — or asking for it without reporting. |
| Seeds | The objective's own randomness, and one stream per replicate of a multi-seed fan. |
| Checkpoints | Reading the reference a forked or resumed trial inherited, and recording one for whatever inherits from this trial. A reference, never bytes. |
| Constraints | Recording constraint values alongside the objective, for a constrained study. |
| Budget and identity | The trial's number, its ids, and what remains of the study's budget. |
The chain matters when you write an objective that suggests conditionally: a relative draw is used only when it was made under exactly the distribution you ask for, and otherwise the independent path answers, keyed on the parameter's name and distribution. Search spaces is the full story.
For determinism, take every random number from the seeds the context hands you. Ambient entropy cannot be saved by anything else in the system, and a process-global generator that nothing seeds is not reproducible in one language, let alone two.
Errors are how you reach the other terminal states¶
An objective does not set a trial's state; the error it returns does.
| Return | The trial becomes |
|---|---|
Ok(outcome) |
complete, with those values |
Error::TrialPruned |
pruned, keeping its last intermediate value |
Error::TrialPaused |
paused, keeping its checkpoint reference |
| any other error | failed, with the message kept in the record |
| a panic | failed — caught per trial, so one bad configuration cannot poison the study. Two cases cannot be caught and are documented rather than papered over: a build without the system feature, and a profile compiled with panic = "abort". |
The first two are sentinels, not faults: report returns them, and letting ?
propagate is the idiomatic path rather than a shortcut — the study listing below
carries the ctx.report(step, &values)? call, applied to this curve:
/// The loss of configuration `x` after `step` resource units.
///
/// `x + LATE_PENALTY / step` — decaying towards `x`, so more resource is always
/// better and a pruned trial records a *worse* number than a survivor (see the
/// module header). `f64::from` on a `u32` is a lossless widening, matching the
/// Python arm's `float(step)`.
fn learning_curve(x: f64, step: u32) -> f64 {
x + LATE_PENALTY / f64::from(step)
}
def learning_curve(x: float, step: int) -> float:
"""The loss of configuration ``x`` after ``step`` resource units.
``x + LATE_PENALTY / step`` — decaying towards ``x``, so more resource is
always better and a pruned trial records a *worse* number than a survivor
(see the module docstring). ``float(step)`` is a lossless widening, matching
the Rust arm's ``f64::from(step)``.
"""
return x + LATE_PENALTY / float(step)
def objective(trial: atune.Trial) -> float:
"""Trains one configuration, reporting its loss at every step."""
x = trial.suggest_float("x", LOW, HIGH)
for step in range(MIN_RESOURCE, MAX_RESOURCE + 1):
# `report` raises `atune.Pruned` when the scheduler prunes; letting it
# propagate is the idiomatic path. The trial is then recorded `pruned`
# and keeps the intermediate it last reported — which is why the curve
# has to *fall* (see the module docstring).
trial.report(learning_curve(x, step), step)
# A trial that survives the ladder is worth its converged loss.
return learning_curve(x, MAX_RESOURCE)
let study = Study::builder()
.parallelism(THREADS)
.budget(Budget::trials(TRIALS))
// Successive halving over a 1..27 ladder: rungs at 1, 3 and 9.
.scheduler(Arc::new(AshaPruner::new(
u64::from(MIN_RESOURCE),
u64::from(MAX_RESOURCE),
REDUCTION_FACTOR,
)?))
.sampler(Arc::new(Tpe::new()))
.create(StudyConfig::new(STUDY).with_seed(SEED))?;
study.optimize(|ctx| {
let x = ctx.suggest_f64("x", LOW..=HIGH, Scale::Linear)?;
for step in MIN_RESOURCE..=MAX_RESOURCE {
// `report` returns `Error::TrialPruned` when the scheduler prunes;
// letting `?` propagate it is the idiomatic path. The trial is then
// recorded `Pruned` and keeps the intermediate it last reported —
// which is why the curve has to *fall* (see the module header).
ctx.report(u64::from(step), &[learning_curve(x, step)])?;
}
// A trial that survives the ladder is worth its converged loss.
Ok(learning_curve(x, MAX_RESOURCE).into())
})?;
study = atune.create_study(
direction="minimize",
# Successive halving over a 1..27 ladder: rungs at 1, 3 and 9.
scheduler=atune.schedulers.Asha(
MIN_RESOURCE,
MAX_RESOURCE,
reduction_factor=REDUCTION_FACTOR,
),
sampler=atune.samplers.Tpe(),
seed=SEED,
name=STUDY,
)
study.optimize(objective, n_trials=TRIALS, n_jobs=JOBS)
The three ways to drive a trial¶
| Route | Use it when | What you take on |
|---|---|---|
A closure or an impl Objective |
The evaluation can happen in this process | Nothing. The loop does the rest. |
| The subprocess executor | The thing to evaluate is a program | A command template and a result convention |
ask / tell by hand |
The driver has to stay in charge — a submission script, a queue consumer, a notebook | Your own parallelism, retries and panic handling |
ask and tell are not a lower-tier API: optimize is "a convenience loop over
ask/tell and nothing more". ask still checks the budget, still has storage assign
the contiguous trial number, still derives the sampler seed and still persists the
joint sample as one batch write, so a hand-rolled loop keeps every determinism
property. What it does not keep is what the loop layered on top — the thread
fan-out, the retry policy, and the panic isolation. Take those on knowingly.
tell has three siblings — tell_pruned, tell_failed and tell_paused — which
are how a hand-rolled driver reaches the states an Objective reaches by returning
an error.
There is also a shell-level form of the same handshake: atune ask prints a
sampled value and atune tell records the result, with the study file as the only
thing between them.
Tune any program is that story end to end, as a
transcript the test suite executes.
The subprocess executor, and what it asks of a program¶
The shipped executor spawns a child per trial, feeds it the sampled parameters, and
reads a result back. It lives behind the system feature, because spawning a child
is operating-system code that wasm32 does not have.
Substitution is argv-level and shell-free: a program and its argument templates
are handed to the process API as an explicit argument vector, so there is no
/bin/sh -c, no word splitting, no glob expansion, and no shell-metacharacter
surface. A sampled value of ; rm -rf / is one literal argument.
The contract the child honours has two halves:
| Half | Contract |
|---|---|
| Output | The objective is the last non-empty line of standard output: a bare number, or JSON with a value or values field. The same line may carry a checkpoint reference and a step. |
| Exit status | 0 completes the trial with the parsed value; 42 (configurable) prunes it; anything else, or a fatal signal, fails it with stderr kept in the record. A 0 exit whose output carries no readable objective is a clean failure — a program that succeeded and printed garbage is a bug in the program, recorded as a failed trial, never a crash in the tuner. |
Each child is also given its trial id, trial number and objective seed in the environment; the study id when the executor is driving the study itself; and, for a forked or resumed trial, a checkpoint reference to restore from. Optionally every sampled parameter is exported too, so a program that prefers to read its configuration from the environment can. The variable names are on the environment reference; the handshake is the subject of Tune any program.
The executor does not sandbox the child. A subprocess trial runs with the same privileges, environment and filesystem access as the process that launched it, and the trust boundary is the one the user already accepted by naming the program.
Writing your own executor¶
Implement Objective, and inside evaluate do whatever getting a number costs
you: submit a job and block on it, post to a queue and await a reply, drive a rig.
You get the study loop's parallelism for free by setting the study's parallelism —
your evaluate is simply called on several threads, so keep it Send + Sync and
keep any shared client behind a lock or a pool.
Two things not to do. Do not sample parameters yourself and hand them in: suggest
them through the context, so trial n gets the sampler's decision for n and
nothing else — that is exactly what the subprocess executor does, iterating the
declared schema in order and injecting nothing. And do not spawn your own trial
loop when an Objective will do; reach for ask/tell only when the driver
genuinely cannot be called by atune.
Where to go next¶
| If you want to | Go to |
|---|---|
| Tune a program from the shell instead | Tune any program |
| Report intermediates and have them acted on | Prune and schedule |
| Write a resumable objective for a population | Population-based training |
| Understand what a trial is, and its states | Studies and trials |
| Keep the reproducibility promise in your objective | Determinism |
| Read the trait, method by method | Rust API |