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:
- replay — a name already suggested in this trial returns the recorded value after a compatibility check, and is never resampled;
- enqueued/fixed — a parameter fixed by the trial’s
TrialTemplatewins over any sampler; - single-valued — a support with one point is assigned, not sampled;
- 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;
- 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>
impl<'a> TrialCtx<'a>
pub const fn is_pruned(&self) -> bool
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
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 budget(&self) -> &Budget
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
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>
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
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>
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
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>
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>
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<()>
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<()>
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<()>
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>
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>
pub fn suggest_int( &mut self, name: &str, range: RangeInclusive<i64>, scale: Scale, ) -> Result<i64>
pub fn suggest_cat(&mut self, name: &str, choices: &[&str]) -> Result<usize>
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>
pub fn suggest_bool(&mut self, name: &str) -> Result<bool>
pub fn suggest_declared(&mut self, name: &str) -> Result<ParamValue>
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>
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>
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>
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<()>
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
Decision | Effect |
|---|---|
Continue | Ok(()) |
Prune | the trial is marked pruned and Error::TrialPruned is returned |
Pause | the trial is marked paused and Error::TrialPaused is returned |
Fork | the 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<()>
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.