Skip to content

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