Skip to main content

TrialCtx

Struct TrialCtx 

pub struct TrialCtx<'a> { /* private fields */ }
Expand description

The handle an objective uses to talk to the study.

It carries the trial’s identity and seeds, the suggest API (the define-by-run path), intermediate reporting, the prune signal, and a view of the budget.

§The sampling priority chain

Every suggest_* resolves a name through exactly one chain — the guide summarises it under what a trial’s parameters actually are — in this order:

  1. replay — a name already suggested in this trial returns the recorded value after a compatibility check, and is never resampled;
  2. enqueued/fixed — a parameter fixed by the trial’s TrialTemplate wins over any sampler;
  3. single-valued — a support with one point is assigned, not sampled;
  4. relative — the joint sample the sampler drew at ask time, if it covers the name under exactly the distribution being asked for and the value lies in that support;
  5. independent — otherwise Sampler::sample_independent.

Step 4 deliberately demands an exact distribution match rather than mere compatibility: a draw made under a drifted support is a different value, and for a define-by-run study which support the inferred space carries depends on what had completed when the trial was asked. Falling through to step 5 keeps the resolved value a pure function of (sampler seed, name, distribution) — the determinism contract — instead of a function of thread interleaving.

§Determinism

seed is the objective’s supply of randomness. Use it — ctx.seed(Stream::Objective) for the environment, Stream::Replicate(k) for the k-th repetition of a multi-seed evaluation — rather than any ambient entropy, and a trial becomes reproducible from (study seed, trial number) alone.

Implementations§

§

impl<'a> TrialCtx<'a>

pub const fn is_pruned(&self) -> bool

true once a scheduler has answered Decision::Prune for this trial.

The study loop consults this rather than trusting the objective’s return value: let _ = ctx.report(step, &[loss]); discards the sentinel, and a trial whose fate the scheduler has already decided must not be recorded as Complete merely because the user’s loop kept going. An out-of-tree driver (the atune_py bindings run their own ask/tell loop) must consult it for the same reason — a Python objective that catches atune.Pruned and returns a value has still been pruned.

pub const fn is_paused(&self) -> bool

true once a scheduler has answered Decision::Pause for this trial.

Consulted by the study loop (and any out-of-tree driver such as the atune_py bindings) like is_pruned: the pause is authoritative whether or not the objective propagated the Error::TrialPaused sentinel.

pub const fn meta(&self) -> &TrialMeta

The trial’s identity and precomputed sampler seed.

pub const fn budget(&self) -> &Budget

What this study is allowed to spend.

RL objectives need it: a run that does not know its step budget cannot size its own schedule.

pub const fn params(&self) -> &Assignment

What this trial’s suggest_* calls have resolved so far, by name.

The objective’s own view of its configuration mid-run — what a log line, a checkpoint directory name or a Trial.params binding reads. It grows as the objective suggests: a name it has not asked for yet is absent even when a template fixed the value, because until it is suggested no distribution has been attached to it and the trial has not decided it. Every entry is exactly what a repeat suggest of that name would replay (step 1 of the priority chain).

pub fn distribution(&self, name: &str) -> Option<&Distribution>

The distribution name was suggested under in this trial, or None if it has not been suggested.

The companion of params, and not a convenience: a categorical’s ParamValue::Cat is an index into the choice set of the distribution it was drawn from, so a reader that resolves a parameter back to a value needs both halves.

pub const fn seed(&self, stream: Stream) -> u64

A seed for one of this trial’s random streams.

Equal to seed_for(study_seed, trial_number, stream) — a pure function of the trial number, so it does not depend on which worker ran the trial.

pub fn replicate_index(&self) -> Option<u32>

The replicate index currently being evaluated, if this trial is one replicate of a multi-seed fan.

None for an ordinary single-seed evaluation.

pub fn replicate_seed(&self) -> u64

The seed the objective should run its environment under.

This is the accessor a multi-seed objective reads instead of seed(Stream::Objective): when the study loop is driving a SeedProtocol fan it returns the replicate’s seed — common across trials under Pairing::Paired, which is the common-random-numbers variance reduction — so trial i and trial i + 1 evaluate their replicate j under identical randomness. Outside a fan it is exactly seed(Stream::Objective), so an objective written for multi-seed still works in a single-seed study.

pub fn parent_checkpoint(&self) -> Option<&str>

The checkpoint reference this trial should start from, if any.

Some in exactly two cases, both resolved by the study loop at ask time (see population-based training):

  • a forked child — the reference is the parent’s latest checkpoint, so the child continues the parent’s run (PBT exploit);
  • a resumed trial — the reference is the trial’s own latest checkpoint, recorded before it was paused.

None for an ordinary trial. It is an opaque reference — a run-directory path, an artifact id — that atune never reads (record_checkpoint wrote it); an objective (or the subprocess it launches) is what restores from it. For a Subprocess trial the same reference is handed to the child as the ENV_CHECKPOINT environment variable.

pub fn parent_step(&self) -> Option<u64>

The resource step the parent_checkpoint was recorded at, if this trial starts from one.

Some(step) in exactly the two cases parent_checkpoint is Some — a forked child (the parent’s latest checkpoint step) or a resumed trial (its own latest checkpoint step) — and None for an ordinary trial, both resolved by the study loop at ask time from the very (step, reference) pair the checkpoint reference itself came from.

An objective that reports intermediate values against a resource axis uses this to continue that axis across the boundary. A fork child inherits its parent’s carried training, so its first report must land at parent_step + its own progress — not back at zero, where a successive-halving scheduler (Pbt/AshaPruner) would rank it against a rung cohort it has already outgrown (the PBT depth contract). An ordinary trial reads None and reports from zero, exactly as before.

pub fn set_user_attr( &mut self, key: impl Into<String>, value: impl Into<Value>, ) -> Result<()>

Inserts or replaces one JSON user attribute on this trial.

Keys beginning with atune: are reserved for generated interoperability metadata. Like other live-trial writes, this is ownership-fenced and becomes visible in the next storage sync. Read-only held-out replay leaves the finished source trial unchanged.

§Errors

Error::Conflict for a reserved key; otherwise any lifecycle/storage error from the fenced mutation.

pub fn record_checkpoint( &mut self, step: u64, reference: impl Into<String>, ) -> Result<()>

Records a checkpoint reference for this trial at step.

atune stores the reference and never reads its bytes: reference is whatever the objective wrote — a run-directory path, an artifact id — and the framework only hands it back to a later Fork or resume of the trial, through parent_checkpoint. Recording a reference at a step already present replaces it; the most advanced one is what a fork or resume starts from.

It is kept as typed state (Scope::Named, keyed by the trial), so it survives a resume and is readable through Study::trial_checkpoints. A study that never calls this writes no checkpoint state.

§It is a no-op on a read-only replay

Like record_constraints, and for the same reason: the held-out re-evaluation stage (Study::reevaluate) replays a finished trial’s configuration under the test seeds, and a finished trial is immutable. A reference recorded there would replace the trial’s most advanced tune-stage checkpoint, so a later Fork of that trial would resume from a test-seed run. A resumed trial is not affected — it is live, and records normally.

§Errors

Error::Storage if the reference cannot be written or an existing one cannot be decoded.

pub fn record_constraints(&mut self, values: &[f64]) -> Result<()>

Records this trial’s constraint values — the feasibility-first side-channel described under Constraints.

≤ 0 is satisfied, > 0 is violated (Optuna’s convention, so a constraint function ported from there keeps its sign). The magnitude matters: it is what ranks two infeasible configurations against each other, driving an infeasible population back toward the feasible region rather than leaving it to drift.

A multi-objective sampler (Nsga2) then ranks by constrained_dominates — a feasible solution beats an infeasible one whatever their objectives say — and Study::pareto_front reports the feasible front. A study whose trials never call this is ranked by plain Pareto dominance and writes no constraint state at all.

The values are kept as typed state (Scope::Named, keyed by the trial — never an extra objective column and never a new storage operation, see pareto::constraint), so they survive a resume, are readable through Study::trial_constraints, and reach the sampler through FrozenTrial::constraints. Calling it twice with non-empty values within one evaluation replaces the previous values. An empty slice is a no-op: it declares no constraints and does not create or mutate the canonical constraint state, so a later non-empty call in the same evaluation can still declare the vector.

§A multi-seed fan records one vector per replicate

Under a SeedProtocol fan the objective runs k times for one trial, so this is called once per replicate and the calls do not overwrite each other. Each replicate’s vector is kept on its ReplicateResult::constraints — exactly where its per-seed objective values are kept — and the trial carries their componentwise worst (worst_constraints), because a configuration counts as feasible only if it is feasible under every seed. A configuration that satisfies its constraints on two replicates and violates them on the third is therefore ranked infeasible, which is the conservative reading and the only one that makes the fan’s aggregate objective trustworthy.

§It is a no-op on a read-only replay

Study::reevaluate replays a finished trial’s fixed configuration under the held-out test seeds. A finished trial is immutable, so that context resolves parameters without writing them — and this method likewise records nothing and returns Ok(()). Its arguments are still validated, so a non-finite value is still an error wherever it is called. Without the gate the test stage would overwrite the trial’s tune-stage constraints with the last test replicate’s, silently changing which trials Study::pareto_front reports.

A resumed trial is deliberately not covered: it is live rather than finished (only its parameters are settled), so it records its constraints exactly as any other running trial does.

§Errors

Error::OutOfRange if any value is non-finite: a NaN constraint is a constraint that could not be evaluated, and a ±∞ one cannot survive the JSON state blob (it round-trips as null), so both are refused at the source rather than silently changing meaning later. Error::Storage if the values cannot be written.

study.optimize(|ctx| {
    let x = ctx.suggest_f64("x", 0.0..=10.0, Scale::Linear)?;
    // "x must be at most 4": satisfied while x - 4 <= 0.
    ctx.record_constraints(&[x - 4.0])?;
    Ok(x.into())
})?;

pub fn suggest_f64( &mut self, name: &str, range: RangeInclusive<f64>, scale: Scale, ) -> Result<f64>

Suggests a float in range.

The first call for a name samples and persists the value; a repeat call replays the stored value after a compatibility check.

§Errors

Error::InvalidSpace if the range is malformed (see Distribution::new_float); Error::Incompatible if the name was already used with an incompatible distribution; Error::TrialPruned if the trial was pruned meanwhile.

pub fn suggest_int( &mut self, name: &str, range: RangeInclusive<i64>, scale: Scale, ) -> Result<i64>

Suggests an integer in range.

§Errors

As suggest_f64.

pub fn suggest_cat(&mut self, name: &str, choices: &[&str]) -> Result<usize>

Suggests one of choices, returning its index.

The index refers to choices as given; the labels are persisted with the trial, so a later run that passes a different choice set is rejected rather than silently reinterpreted.

§Errors

As suggest_f64, plus Error::InvalidSpace if choices is empty or contains duplicates.

pub fn suggest_bool(&mut self, name: &str) -> Result<bool>

Suggests a boolean.

§Errors

As suggest_f64.

pub fn suggest_declared(&mut self, name: &str) -> Result<ParamValue>

Suggests name using the distribution declared for it by this trial’s space, rather than one the caller restates.

This is what objective-side code that already holds a fully declared SpaceSchema actually wants: walking the schema and calling suggest with each spec’s own distribution only hands back a range the study already knows, and ties the caller to keeping the whole schema around just to do that. Here the caller supplies a name and nothing else; the range comes from the trial’s ask-time space — the same one suggest’s relative step already draws from. Afterwards, distribution reads back what name was resolved under, which is what a caller that renders the value (say, a categorical index back to its label) needs and this method does not itself return — except in the resumed/held-out case below, where there is nothing real to read back.

Step 1 (replay) needs no distribution and always runs first: a name already resolved this trial keeps its value, in every trial state. Past that, this method never invents a Distribution to make a later step succeed. A distribution it did not get from a real source — the ask-time space, or (transitively) a prior suggest call this trial — is never persisted, never recorded, and never handed to suggest as a stand-in; when none is available, resolution stops with Error::UnknownParam instead. A fabricated distribution would not just be a wrong internal detail: it is what distribution reads back afterward, what gets written to the durable trial record on a live ask, and what a categorical value’s label would (wrongly) be resolved against.

§Resumed and held-out trials

The ask-time space this method otherwise falls back to is only populated for a fresh, live ask. A resumed trial and a held-out re-evaluation both replay a recorded configuration by value instead — the trial’s own record outranks any sampler — and neither carries the ask-time space that produced it, nor allows anything to be written back onto the trial. So there, name resolves straight from that record with no distribution consulted at all: Error::UnknownParam if name is not in it, the recorded value otherwise. A following distribution read for name answers from the trial’s own per-trial audit trail — the distribution each recorded value was actually drawn under, which the replay context is seeded with — so a caller that must render the value (a categorical index back to its label, say) has the one honest source. Nothing is invented: a name the record never drew still reads back None.

§A live ask and an undeclared name

A live ask’s name being fixed (enqueued, forked, or retried) does not by itself resolve name: unlike a resumed or held-out trial, a live ask’s space is missing name only because the study — or its currently inferred relative space — genuinely carries no such range, not because one is deliberately being withheld. So a fixed name the ask-time space does not declare is Error::UnknownParam here, the same as an unfixed one. When the space does declare name, suggest is called with that real distribution and resolves and persists exactly as it always has — fixed still outranks the sampler there, through suggest’s own priority chain, with no special case needed in this method for that path.

§Errors

Error::UnknownParam if name is not already resolved this trial and either: this is a resumed or held-out trial and name is not in its fixed record, or this is a live ask and its ask-time space does not declare name. Otherwise whatever suggest returns for the distribution it resolves under.

pub fn suggest_open_f64(&mut self, name: &str, open: Open) -> Result<f64>

Suggests a float whose range may exceed the seed you pass — the define-by-run declaration of an open parameter (§6.3): the first call for name registers open in the study’s per-parameter policy slot, write-once (§7.2), and every later call must pass an equal policy or is refused.

The value is drawn from the live range: the ask-time space where it carries name (which, once growth is active, is the widened range), and the seed itself on first use. The method’s own name is what keeps the signature honest — the returned value may exceed the seed, and nothing lies.

On a resumed trial or a held-out replay the recorded value is returned and nothing is registered — an immutable record takes no policy write (§10.5).

§Errors

Error::InvalidSpace/Error::Unsupported if open fails its own declaration-time validation (§7.3, §7.4, §9.6), or its seed is not a float distribution; Error::Incompatible if name was already declared with a different policy (§7.2), naming both; Error::UnknownParam on a settled replay whose record never drew name; otherwise as suggest.

pub fn suggest_open_int(&mut self, name: &str, open: Open) -> Result<i64>

Suggests an integer whose range may exceed the seed you pass — suggest_open_f64’s integer twin, with the same registration, write-once and replay rules.

§Errors

As suggest_open_f64, with the kinds swapped.

pub fn suggest(&mut self, name: &str, dist: &Distribution) -> Result<ParamValue>

Suggests a value from an explicit distribution.

The primitive the typed helpers above are written in terms of; also the path a generated impl Space (M2) takes. It walks the priority chain documented on TrialCtx and persists whatever it resolves, so that the trial record always explains itself.

The returned value always satisfies Distribution::contains for dist — with one documented exception: a repeat suggest replays exactly what was recorded, even if dist has meanwhile narrowed around it (Optuna’s rule; the recorded value is the truth about what was evaluated).

§Errors

Error::InvalidSpace if dist is malformed; Error::Incompatible if the name was already recorded — in this trial or, through storage’s study-wide gate, in an earlier one — with an incompatible distribution; Error::OutOfRange if an enqueued fixed value does not lie in dist; Error::Sampler if the sampler answers outside the support; Error::TrialPruned if the trial has already been pruned; Error::Storage on a backend fault.

pub fn report(&mut self, step: u64, values: &[f64]) -> Result<()>

Reports intermediate values at step.

step counts the study’s declared ResourceUnit. A live trial renews its lease independently of reporting; this call persists the report and gives the scheduler its decision point. Call should_stop right after to act on it — or simply propagate this call’s own error, which carries the same signal.

§What the scheduler’s answer does
DecisionEffect
ContinueOk(())
Prunethe trial is marked pruned and Error::TrialPruned is returned
Pausethe trial is marked paused and Error::TrialPaused is returned
Forkthe fork is buffered for the study loop; Ok(()) — a fork does not stop this trial

Pause and Fork are executed from M4.0. A Pause stops the trial and the study loop records it Paused — a resumable, non-terminal state that keeps the trial’s checkpoint reference. A Fork does not stop the reporting trial (Decision::Fork is PBT’s exploit-and-explore, which spawns a sibling): it is recorded on the context and the study loop materializes a child trial from it once this trial ends. No built-in scheduler produces either (NopScheduler and the M3.1 pruners answer only Continue/Prune), so both fire only for a population scheduler (M4.1 PBT onward).

A scheduler that fails is treated as “no decision” and the trial continues, exactly as Scheduler::on_report specifies. The view it is handed is the ask-time snapshot: a report happens inside the objective’s inner loop and must not cost a storage round trip.

§Errors

Error::TrialPruned if the scheduler pruned the trial; Error::TrialPaused if the scheduler paused the trial; Error::Conflict if the trial has already finished; Error::Storage on a backend fault.

pub fn should_stop(&mut self) -> Result<()>

Asks whether the trial should stop.

Returns Ok(()) to continue and Error::TrialPruned to stop, so an objective can simply write ctx.should_stop()?; in its training loop and let ? do the rest.

fn train(ctx: &mut TrialCtx<'_>) -> Result<Outcome> {
    for step in 0..100_u32 {
        let loss = 1.0 / f64::from(step + 1);
        ctx.report(step.into(), &[loss])?;
        ctx.should_stop()?; // returns `Error::TrialPruned` when pruned
    }
    Ok(0.0.into())
}
§Errors

Error::TrialPruned if a scheduler decided to prune this trial; Error::TrialPaused if a scheduler decided to pause it.

It answers from the decision report already obtained rather than re-reading storage: the check sits in an inner training loop and must stay free. A trial pruned by another worker is therefore not observed here — no built-in scheduler prunes a trial it does not own, and the executor is what makes remote prunes real.

Trait Implementations§

§

impl Debug for TrialCtx<'_>

§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Shows the trial’s identity and what it has resolved so far.

The storage, sampler and scheduler handles are omitted: none of the three seams requires Debug, and a context is not the place to demand it.

Auto Trait Implementations§

§

impl<'a> !RefUnwindSafe for TrialCtx<'a>

§

impl<'a> !UnwindSafe for TrialCtx<'a>

§

impl<'a> Freeze for TrialCtx<'a>

§

impl<'a> Send for TrialCtx<'a>

§

impl<'a> Sync for TrialCtx<'a>

§

impl<'a> Unpin for TrialCtx<'a>

§

impl<'a> UnsafeUnpin for TrialCtx<'a>

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more