Struct Study
pub struct Study { /* private fields */ }Expand description
A study: storage, sampler, scheduler, clock and configuration, bound.
A handle is one worker. Several handles — threads, processes, machines — may drive the same study through the same storage at the same time; they coordinate through storage alone and never through each other (several workers, one study).
use atune_core::clock::ManualClock;
use atune_core::space::Scale;
use atune_core::study::{Budget, Study, StudyConfig};
use std::sync::Arc;
let study = Study::builder()
.clock(Arc::new(ManualClock::new(0)))
.budget(Budget::trials(16))
.create(StudyConfig::new("quadratic").with_seed(7))?;
study.optimize(|ctx| {
let x = ctx.suggest_f64("x", -5.0..=5.0, Scale::Linear)?;
Ok((x * x).into())
})?;
assert_eq!(study.trial_count()?, 16);
assert!(study.best_trial()?.is_some());Implementations§
§impl Study
impl Study
pub fn trial_fan(&self, trial: TrialId) -> Result<Option<ReplicateFan>>
pub fn trial_fan(&self, trial: TrialId) -> Result<Option<ReplicateFan>>
The per-seed replicate fan of a multi-seed trial, if it has one.
Ok(None) for a single-seed trial before optional re-evaluation (no fan
was stored). The fan carries both the tune replicates the sampler’s
objective aggregates over and — if the final
reevaluate stage has run — the disjoint test
replicates. A fan-free study may therefore have a test-only fan after
re-evaluation.
§Errors
Error::Conflict if a same-version fan violates the producer’s row
shape; Error::Storage if the blob cannot be decoded or the backend
faults.
pub fn trial_with_fan(&self, trial: TrialId) -> Result<FrozenTrial>
pub fn trial_with_fan(&self, trial: TrialId) -> Result<FrozenTrial>
A trial’s snapshot with its per-seed fan attached.
get_trial leaves
FrozenTrial::replicates empty —
storage is fan-agnostic and the sampler never needs it — so this reader
fills it from the trial’s fan blob for a caller that does want the
per-seed sub-results (a report, a dashboard, M3.1’s Wilcoxon pruner). It
carries the tune replicates; the test replicates stay in
trial_fan.
§Errors
Error::NotFound if the trial does not exist; Error::Storage on a
backend fault.
pub fn optimize<F>(&self, objective: F) -> Result<()>
pub fn optimize<F>(&self, objective: F) -> Result<()>
Runs trials until the budget is spent.
A convenience loop over ask/tell and nothing more — anything it can do,
a caller can do by
hand with ask and tell.
Sequential by default. With
parallelism(n) and n > 1
the trials run
on std::thread::scope, which needs the system feature; with
n == 1 the code path contains no thread spawn at all, which is
what keeps optimize usable on wasm32-unknown-unknown.
§What stops the loop
| Condition | Result |
|---|---|
| budget spent | Ok(()) |
Error::SpaceExhausted from the sampler | Ok(()) — a grid search ends this way |
| a failing objective | the trial is recorded Failed (and maybe retried); the loop continues |
| a panicking objective | as a failure, when it can be caught — see below |
| a storage or sampler fault | Err(..), after the trial in flight is recorded |
§Panics in the objective
With the system feature the objective is run inside
std::panic::catch_unwind and a panic is recorded as a failed 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 (there is no catch_unwind on the wasm path), and
a profile compiled with panic = "abort". In both, a panicking
objective takes the process down and the trial is left Running for a
later fail-over.
§Determinism
Trial-number-indexed
determinism
— trial n gets the same parameters however many workers ran
— holds for a stateless sampler with an empty template queue. Both
enqueue and a non-zero
RetryPolicy make the
number-to-template mapping order-dependent, because when a template is
queued is itself thread-scheduling dependent; such a study is
replayable rather than pre-determined. Under parallelism(1)
the mapping is fixed in both cases.
§Errors
The first error any worker reports; the other workers finish the trial
they hold first and then stop, rather than running out the remaining
budget. Error::Conflict if parallelism > 1 was requested from a
build without the system feature.
pub fn optimize_with(&self, objective: &dyn Objective) -> Result<()>
pub fn optimize_with(&self, objective: &dyn Objective) -> Result<()>
optimize for an Objective that is not a closure.
The general entry point, and the one both forms share: a hand-written
impl Objective, a boxed trait object, an objective chosen at run time.
optimize exists alongside it purely so that a
closure needs no type annotation — a bound of impl Objective gives the
compiler nothing to infer a closure’s argument type from, whereas a
function-trait bound does.
§Errors
As optimize.
§impl Study
impl Study
pub fn ask(&self) -> Result<Option<TrialCtx<'_>>>
pub fn ask(&self) -> Result<Option<TrialCtx<'_>>>
Creates the next trial and samples it.
The sequence is fixed:
- check the budget — deadline against the injected clock, fidelity against the synced view, trial count against a local reservation;
- one storage sync, folded into the handle’s view through its cursor;
- create the trial (storage assigns the contiguous, race-safe number);
- derive
sampler_seed = seed_for(study_seed, number, Stream::Sampler); - infer the relative space and sample it jointly;
- persist the whole sample as one batch write;
- transition
Waiting -> Runningand hand back the context.
Ok(None) means the budget is spent — a normal stop, not a failure.
§Budgets under parallelism
The handle first claims a local slot with an atomic compare-and-swap,
preventing its own threads from needlessly racing for the last slot.
The authoritative check and creation are one
TrialLifecycleStorage::reserve_and_create_if_budget operation, so
independently opened handles and processes cannot overshoot either.
max_trials counts durable trial records already in the study,
including records whose post-create start later fails.
§What the batch write costs a define-by-run study
Persisting the joint sample up front is what makes a declared-space
trial cost one parameter write instead of one per suggest. It has
one consequence worth stating: a parameter that the inferred relative
space contains but the objective does not go on to suggest — a
conditional branch not taken — is still recorded on the trial. For a
study with a declared SpaceSchema that is
exactly right (the space is the space). For a define-by-run study the
inferred space depends on what had completed when the trial was asked,
so under parallelism two runs may record different unsuggested extras
on the same trial number. The values actually suggested are unaffected:
a relative draw is used only when it was made under exactly the
distribution the objective asks for (step 4 of the chain on
TrialCtx), and otherwise the independent path — keyed on
(sampler seed, name, distribution) — answers. Determinism promise 2
therefore holds for everything the objective read.
§Errors
Error::SpaceExhausted when an exhaustive sampler has handed out
every configuration — a normal stop condition that
optimize treats as the end of the study. The trial
it was discovered on keeps its number, becomes durably Failed with the
reason recorded, and is invisible to samplers;
Error::LostRace if another worker claimed the freshly created trial
before this handle could start it; plus anything storage or the sampler
reports.
A failed ask does not consume a queued template. Every error path
below returns the template to the front of the queue, so an
enqueued configuration or a pending retry survives a
lost race, a backend fault or an exhausted sampler and is used by the
next successful ask.
§impl Study
impl Study
pub fn reevaluate<F>(&self, objective: F, top_k: usize) -> Result<FinalReport>
pub fn reevaluate<F>(&self, objective: F, top_k: usize) -> Result<FinalReport>
The final re-evaluation stage: re-rank the top configurations on the held-out test seeds (re-evaluate on held-out seeds).
This is the guard against the ICML-2023 failure mode — configurations
that overfit their tuning seeds. After the optimization budget is spent,
it takes the top_k completed trials by the canonical constraint-aware
tune ranking (feasibility first, then aggregate), re-evaluates each
one’s configuration under the protocol’s disjoint test seeds, and
reports:
- the test-seed-ranked best (which is not necessarily the tune-ranked best — that is the whole point);
- per configuration, its tune value, its test value, and the signed
overfit_gapbetween them.
The test replicates are stored on each re-evaluated trial’s fan (readable
through trial_fan); the trial’s sampler-visible
objective — its values, and therefore the sampler’s observation — is
left untouched, so the tune aggregate remains the objective used by
the sampler. Study-owned ranking continues to apply the recorded
constraints first. In other words:
- before / without
reevaluate:best_trialis the canonical constraint-aware tune-ranked best. - after
reevaluate:best_trialis still the canonical constraint-aware tune-ranked best; the test-ranked best isFinalReport::best, which is the study’s honest answer.
Nothing else changes about a study, and calling it is optional — it runs only when the caller invokes it, and only if the protocol declares test seeds.
§Errors
Error::Conflict if the study is multi-objective (there is no total
order to rank a test best by) or if the protocol declares no test seeds;
plus anything storage or the objective reports.
pub fn reevaluate_with(
&self,
objective: &dyn Objective,
top_k: usize,
) -> Result<FinalReport>
pub fn reevaluate_with( &self, objective: &dyn Objective, top_k: usize, ) -> Result<FinalReport>
reevaluate for an Objective that is not a
closure (the Subprocess executor, a boxed trait object).
§Errors
As reevaluate.
§impl Study
impl Study
pub fn retry_record(&self, trial: TrialId) -> Result<Option<RetryRecord>>
pub fn retry_record(&self, trial: TrialId) -> Result<Option<RetryRecord>>
What a trial records about being a retry, if it is one.
§Errors
Error::Storage if the blob cannot be decoded or the backend faults.
pub fn enqueue(&self, template: TrialTemplate) -> Result<()>
pub fn enqueue(&self, template: TrialTemplate) -> Result<()>
Queues a trial to be created with something already decided.
The enqueued parameters outrank every sampler: they are step 2 of the
priority chain documented on TrialCtx. Templates are consumed in
FIFO order by ask; once the queue is empty, trials are
sampled normally again. An ask that takes a template but then fails
before the trial is running puts it back at the front of the queue,
so an explicitly requested configuration is never lost.
§Determinism: an enqueued study is replayable, not pre-determined
Trial-number-indexed
determinism
holds for a stateless sampler with an empty template
queue. Enqueuing makes the number-to-template mapping order-dependent:
which trial number a template lands on is decided by which
ask claims it, and under
parallelism(n) with n > 1
that is
thread scheduling. The values a template fixes are of course exactly
what was enqueued; it is the pairing with a trial number that floats.
Such a study is replayable (its history explains itself) rather than
pre-determined (computable from the seed before it runs).
§Errors
Error::Storage if the internal queue lock is poisoned.
§impl Study
impl Study
pub fn tell(&self, ctx: TrialCtx<'_>, outcome: Outcome) -> Result<()>
pub fn tell(&self, ctx: TrialCtx<'_>, outcome: Outcome) -> Result<()>
Completes a trial with its objective values.
Values and the terminal state are committed atomically under the context’s owner/epoch fence. A stale worker therefore cannot complete a trial after another worker has reclaimed it.
§Errors
Error::Conflict if the value count does not match the study’s
directions or the trial has already finished; Error::Scheduler if the
scheduler asks to resume a trial that is not paused; plus anything storage
or a stateful seam reports.
pub fn tell_pruned(&self, ctx: TrialCtx<'_>) -> Result<()>
pub fn tell_pruned(&self, ctx: TrialCtx<'_>) -> Result<()>
Records a trial as pruned.
No values are written. A pruned trial’s objective value is its
last intermediate report
(FrozenTrial::objective_value),
and writing a copy of it would only create a second source of truth
that could disagree. A trial pruned before its first report therefore
has no objective value at all, which is the honest answer.
§Errors
As tell, minus the value-count conflict.
pub fn tell_failed(&self, ctx: TrialCtx<'_>, message: &str) -> Result<()>
pub fn tell_failed(&self, ctx: TrialCtx<'_>, message: &str) -> Result<()>
Records a trial as failed, keeping message.
The error text and terminal state are committed atomically under the
context’s owner/epoch fence. The record, its parameters and the message
all survive; only the sampler’s view of the trial does not
(StudyView::visible filters failures out).
§Errors
As tell.
pub fn tell_paused(&self, ctx: TrialCtx<'_>) -> Result<()>
pub fn tell_paused(&self, ctx: TrialCtx<'_>) -> Result<()>
Suspends a trial, keeping its checkpoint — the execution of
Decision::Pause.
The trial moves Running -> Paused, a non-terminal state: no values
are written, its recorded checkpoint reference is left in place, and it
can be woken later with
Command::ResumeTrial. Because
a paused trial is not finished, the sampler’s
after_trial and the scheduler’s
on_trial_end — both defined
as terminal-state callbacks — do not run here; the seam state that a
pause decision may have mutated (in
on_report) is persisted so a
resume across a reopen is exact.
§Errors
Error::Storage on a backend fault; anything a stateful sampler’s or
scheduler’s state reports while its
blob is persisted.
§impl Study
impl Study
pub fn backing_paths(&self) -> Option<Vec<PathBuf>>
pub fn backing_paths(&self) -> Option<Vec<PathBuf>>
Local source files protected from export replacement.
See Storage::backing_paths for the unknown-provenance contract.
pub fn builder() -> StudyBuilder
pub fn builder() -> StudyBuilder
Starts building a study.
Storage is in-memory by default; call
storage for anything durable.
pub const fn config(&self) -> &StudyConfig
pub const fn config(&self) -> &StudyConfig
The configuration the study was created with.
Read from storage when the study was loaded, so a resumed study reproduces the original seed rather than a new one.
pub fn user_attrs(&self) -> Result<UserAttrs>
pub fn user_attrs(&self) -> Result<UserAttrs>
Returns the user-owned JSON metadata attached to this study.
The backing storage is authoritative, so the result is shared across handles and survives a reload.
§Errors
Returns the backing storage’s error if the study does not exist or its metadata cannot be read.
pub fn set_user_attr(&self, key: impl Into<String>, value: Value) -> Result<()>
pub fn set_user_attr(&self, key: impl Into<String>, value: Value) -> Result<()>
Inserts or replaces one user-owned JSON attribute on this study.
Reserved-key validation and persistence are delegated to the backing storage, which keeps this handle a coordination facade.
§Errors
Returns Error::Conflict for a reserved key, or the backing
storage’s error if the study does not exist or the value cannot be
stored.
pub const fn worker(&self) -> WorkerId
pub const fn worker(&self) -> WorkerId
This handle’s worker identity.
Set by StudyBuilder::worker, and stamped onto
FrozenTrial::worker by every lease
this handle claims.
pub fn register_policy(&self, policy: &SpacePolicy) -> Result<()>
pub fn register_policy(&self, policy: &SpacePolicy) -> Result<()>
Persists policy into this study’s per-parameter slots (§7.1 of the
open-search-spaces plan) — the create-time half of declaring open
parameters, shared by StudyBuilder::policy and every surface that
creates its study through
StudyLocator rather than
StudyBuilder::create (the CLI does).
Each Open is validated (§7.3, §7.4, §9.6),
and a policy naming a declared parameter must seed exactly the
declared distribution — the range has one home, not two. Writing is
per-parameter and replace-on-write, so re-registering an equal
policy is idempotent; refusing a disagreement with an already-stored
slot is the suggest-time and resume-time comparison’s job (§7.2), not
this method’s.
An empty policy writes nothing at all — the property that keeps every existing journal golden byte-identical.
§Errors
Error::InvalidSpace/Error::Unsupported from validation, or
Error::Storage on a backend fault.
pub fn live_distribution(
&self,
name: &str,
seed: &Distribution,
) -> Result<Distribution>
pub fn live_distribution( &self, name: &str, seed: &Distribution, ) -> Result<Distribution>
The ask-time space with growth applied (§12.1’s one new line, seen
from the handle): consults the per-parameter policy slots on demand,
advances the replayed growth cache over the view, emits a
StudyEvent::SpaceGrowthDecided per new decision, and returns the
widened space — or space untouched when no parameter is open.
§Errors
Error::Unsupported when a policy exists and either the study is
multi-objective or the sampler persists bound-relative state
(§12.2’s mechanical clause — a fail-closed default for any future
stateful sampler); otherwise storage faults from the slot reads.
The distribution name would be asked under right now: seed
widened by the replayed growth rule, or seed itself when no policy
names name (open-search-spaces §11.1’s second path — the scripted
join_and_sample has no lease to interpose on, so it asks the handle
directly and must record the widened range, not the caller’s seed).
Emits the same growth-decision events an ordinary ask would, so a scripted study’s decisions are observable too.
§Errors
As the ask-path widening: Error::Unsupported for a stateful
sampler or a multi-objective study under a policy, or a storage
fault from the on-demand policy reads.
pub fn policy_for(&self, name: &str) -> Result<Option<Open>>
pub fn policy_for(&self, name: &str) -> Result<Option<Open>>
Reads the stored policy slot for name, if one was ever declared —
the read half of register_policy, for the
CLI’s resume comparison (§7.2 case 1) and atune doctor’s policy
reporting.
§Errors
A foreign blob kind or a future version is Error::Incompatible;
Error::Storage on a backend fault or an undecodable payload.
pub const fn retry_policy(&self) -> RetryPolicy
pub const fn retry_policy(&self) -> RetryPolicy
The retry policy failed trials are subject to.
pub const fn seed_protocol(&self) -> SeedProtocol
pub const fn seed_protocol(&self) -> SeedProtocol
The multi-seed protocol this study evaluates trials under.
The default is single-seed (SeedProtocol::new): no fan, and every
trial is one evaluation.
pub fn view(&self) -> Result<StudyView>
pub fn view(&self) -> Result<StudyView>
A fresh snapshot of the study.
Syncs once — incrementally, from the handle’s cursor — and returns the folded view. This is the read path every convenience method below goes through, and the one a caller should use too: re-reading the whole study per question is exactly the chattiness the cursor exists to kill.
§Errors
Error::NotFound if the study vanished from storage;
Error::Storage on a backend fault or a poisoned internal lock.
pub fn snapshot(&self) -> Result<StudySnapshot>
pub fn snapshot(&self) -> Result<StudySnapshot>
A canonical hydrated observation of the study.
Constraints, replicate fans, space policies, and pending terminal
finalizations are read with the trials through one storage snapshot
request. Built-in backends provide one read epoch; compatible custom
backends expose crate::storage::SnapshotConsistency::BestEffort
through the returned snapshot instead of making an atomicity claim.
Samplers and schedulers continue to use the lighter raw view.
§Errors
A storage read or side-state decoding error.
pub fn best_trial(&self) -> Result<Option<FrozenTrial>>
pub fn best_trial(&self) -> Result<Option<FrozenTrial>>
The best trial of a single-objective study, if there is one — feasibility first.
None for a multi-objective study, whose honest answer is
pareto_front — see StudyView::best.
Like pareto_front this attaches each visible
trial’s recorded constraint values before ranking (storage cannot carry
them on the record), so a trial that violated its constraints never wins
while a feasible one exists, whatever the objectives say. That is the same
relation the Pareto front uses, so the two answers cannot disagree about
feasibility. An unconstrained study is unaffected: with nothing recorded
the ranking is the plain objective comparison it always was.
§Cost
A reporting call through one storage snapshot request. It is not a per-suggest path.
§Errors
As view, plus Error::Storage if a constraint blob
cannot be decoded.
pub fn trial_count(&self) -> Result<usize>
pub fn trial_count(&self) -> Result<usize>
pub fn trial_checkpoints(
&self,
trial: TrialId,
) -> Result<Option<TrialCheckpoints>>
pub fn trial_checkpoints( &self, trial: TrialId, ) -> Result<Option<TrialCheckpoints>>
The checkpoint references a trial recorded, if any.
atune stores checkpoint references, never bytes
(population-based
training): each entry maps a resource
step to an opaque reference string that an
objective wrote through
TrialCtx::record_checkpoint.
Ok(None) when the trial recorded none.
§Errors
Error::Storage if the blob cannot be decoded or the backend faults.
pub fn latest_checkpoint(&self, trial: TrialId) -> Result<Option<String>>
pub fn latest_checkpoint(&self, trial: TrialId) -> Result<Option<String>>
pub fn trial_constraints(&self, trial: TrialId) -> Result<Option<Vec<f64>>>
pub fn trial_constraints(&self, trial: TrialId) -> Result<Option<Vec<f64>>>
The constraint values a trial recorded, if it recorded any.
≤ 0 is satisfied, > 0 is violated
(pareto::constraint). Ok(None) when the
trial declared none. Calling
TrialCtx::record_constraints
with an empty list is also undeclared: it is a no-op and creates no
canonical state. An undeclared trial is distinct from a declared trial
whose values are all satisfied.
§Errors
Error::Storage if the blob cannot be decoded or the backend faults.
pub fn pareto_front(&self) -> Result<Vec<FrozenTrial>>
pub fn pareto_front(&self) -> Result<Vec<FrozenTrial>>
The study’s Pareto front: the non-dominated set of completed trials.
The multi-objective analogue of best_trial, which
correctly answers None for a multi-objective study because a front has
no total order (read the front, not the
best). Trials are returned in
trial-number order, so the answer does not depend on completion
order, and a single-objective study gets the (possibly tied) best trials
— the degenerate case, not a special case.
Ranking is constrained_dominates:
if trials recorded constraint values, a feasible trial outranks an
infeasible one whatever their objectives say, and the front is the
feasible one whenever any trial is feasible.
§Cost
This is a reporting call through one storage snapshot request, not a
hot path. Ranking itself is O(M·N²).
§Errors
As view, plus Error::Storage if a constraint blob
cannot be decoded.
pub fn nadir_point(&self) -> Result<Option<Vec<f64>>>
pub fn nadir_point(&self) -> Result<Option<Vec<f64>>>
The nadir point of the study’s comparable trials: the componentwise worst objective value observed.
The conventional default reference for hypervolume
— see nadir_point for why a hypervolume
history must nonetheless be measured against one fixed reference
rather than a moving nadir. Ok(None) when no trial has a comparable
objective vector yet.
§Errors
As view.
pub fn hypervolume(&self, reference: &[f64]) -> Result<f64>
pub fn hypervolume(&self, reference: &[f64]) -> Result<f64>
The hypervolume the study’s Pareto front covers, bounded by
reference.
The standard scalar quality measure of a multi-objective run: it rewards a front for converging and for spreading out, which no single number derived from one objective can. Larger is better whatever the directions are. Only feasible front members count.
§Errors
As view, plus everything
hypervolume reports — notably
Error::InvalidSpace above
MAX_EXACT_HYPERVOLUME_DIM
objectives, where an exact computation would take exponential time and is
refused rather than silently attempted.
pub fn lineage(&self) -> Result<Vec<LineageEntry>>
pub fn lineage(&self) -> Result<Vec<LineageEntry>>
The study’s fork genealogy — the population tree a forking scheduler
(M4.1 Pbt) builds.
Reads one hydrated snapshot and reconstructs, per trial, its parent and
the recovered explore perturbation (the parameter difference from its
parent).
This is the inspectable lineage the design calls the headline PBT
artifact and the tree the M6 GUI renders (read the
genealogy); the pure
reconstruction is fork_lineage. Entries are
in trial-number order, so the answer does not depend on completion order.
§Errors
As snapshot.