Skip to main content

Study

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

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>

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<()>
where F: Fn(&mut TrialCtx<'_>) -> Result<Outcome> + Send + Sync,

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
ConditionResult
budget spentOk(())
Error::SpaceExhausted from the samplerOk(()) — a grid search ends this way
a failing objectivethe trial is recorded Failed (and maybe retried); the loop continues
a panicking objectiveas a failure, when it can be caught — see below
a storage or sampler faultErr(..), 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<()>

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

pub fn ask(&self) -> Result<Option<TrialCtx<'_>>>

Creates the next trial and samples it.

The sequence is fixed:

  1. check the budget — deadline against the injected clock, fidelity against the synced view, trial count against a local reservation;
  2. one storage sync, folded into the handle’s view through its cursor;
  3. create the trial (storage assigns the contiguous, race-safe number);
  4. derive sampler_seed = seed_for(study_seed, number, Stream::Sampler);
  5. infer the relative space and sample it jointly;
  6. persist the whole sample as one batch write;
  7. transition Waiting -> Running and 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

pub fn reevaluate<F>(&self, objective: F, top_k: usize) -> Result<FinalReport>
where F: Fn(&mut TrialCtx<'_>) -> Result<Outcome> + Send + Sync,

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_gap between 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_trial is the canonical constraint-aware tune-ranked best.
  • after reevaluate: best_trial is still the canonical constraint-aware tune-ranked best; the test-ranked best is FinalReport::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>

reevaluate for an Objective that is not a closure (the Subprocess executor, a boxed trait object).

§Errors

As reevaluate.

§

impl Study

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<()>

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

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<()>

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<()>

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<()>

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

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

Starts building a study.

Storage is in-memory by default; call storage for anything durable.

pub const fn id(&self) -> StudyId

The study’s identity in its backend.

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>

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<()>

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 budget(&self) -> &Budget

What the study is allowed to spend.

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<()>

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>

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>>

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

The retry policy failed trials are subject to.

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>

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>

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>>

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>

How many trials the study has, in any state.

§Errors

As view.

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>>

A trial’s most advanced checkpoint reference, if it recorded one.

This is the reference a Fork child or a resumed trial starts from.

§Errors

As trial_checkpoints.

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>>

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>>>

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>

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>>

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.

Trait Implementations§

§

impl Debug for Study

§

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

The identity and the knobs; the seam handles are opaque by design.

Auto Trait Implementations§

§

impl !Freeze for Study

§

impl !RefUnwindSafe for Study

§

impl !UnwindSafe for Study

§

impl Send for Study

§

impl Sync for Study

§

impl Unpin for Study

§

impl UnsafeUnpin for Study

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