Search-space DSL¶
The two notations for a search space: the string DSL, and the #[derive(Space)]
attributes.
They are two spellings of one engine. uniform(0, 1) and
#[space(range = 0.0..=1.0)] build the same distribution, go through the same
validation and sample identically — so the choice between them is about where the
space is written, not about what it can express. Text is for a program configured
from a file or a command line; the derive is for a Rust struct you already have.
Both sections below are generated from the module documentation of the code that implements them, which is where the grammar is reviewed and where a parser change has to touch it. Every form in the tables is exercised by that module's own doctests and by a round-trip property test, so a form that stopped working would fail the test suite before it could reach this page.
Generated file. Do not edit between the pragmas below — run
cargo dev generate-all --mode write instead. Everything outside them is
hand-written and is never touched.
The string DSL¶
One line of text per parameter, parsed by atune_core::space::dsl. This is what atune run --param and a TOML overlay accept, so a program becomes tunable with no Rust type at all. The parse is total: every input yields either a validated distribution or a message saying what was expected and where.
Generated from the //! documentation of crates/atune_core/src/space/dsl.rs, which is the specification the implementation is reviewed against.
Grammar¶
Whitespace (ASCII or Unicode) is insignificant everywhere. Function names
and the keywords bool, log, step, open, limit, times, plus,
up and down are lower-case and case-sensitive.
| Form | Lowers to | Notes |
|---|---|---|
uniform(low, high) |
Distribution::float |
continuous linear float |
uniform(low, high, step) |
Distribution::float_step |
third arg is the step; step=<v> is also accepted |
loguniform(low, high) |
Distribution::float_log |
a log range cannot be stepped |
int(low, high) |
Distribution::int |
unit-step integer |
int(low, high, log) |
Distribution::int_log |
the bare keyword log |
int(low, high, step=<n>) |
Distribution::int_step |
the step keyword is required for ints |
choice(a, b, c) |
Distribution::cat_labels |
labels in order, stable indices |
bool |
Distribution::boolean |
also written bool() |
<number> |
a fixed value (see below) | e.g. 0.001, 42, 1e-5 |
Numeric literals¶
A literal is read as an integer when it is written with no decimal point
and no exponent (42, -7), and as a float otherwise (0.1, 1e-5,
-2.5E3). This is exactly the "integer vs float inferred from the literal"
rule: it decides the type of a bare fixed value and of an int bound, and
it is why int(2.5, 3) is rejected (an int bound must be an integer
literal) while uniform(0, 1) is fine (an integer literal is accepted
wherever a float is wanted, and widened). Negative numbers and scientific
notation are accepted; NaN and inf literals are rejected (they can
only ever name an invalid distribution).
The step syntax¶
The step is written differently for the two numeric kinds, on purpose:
uniformtakes the step as its third positional argument (uniform(0, 1, 0.25)); the explicituniform(0, 1, step=0.25)is accepted too and means the same thing.intrequires thestep=<n>keyword (int(2, 512, step=4)), because anint's third slot is shared with the barelogkeyword and a bare number there would read ambiguously.
Fixed values¶
A bare number is a parameter that is present but not tuned — it always takes that one value. It lowers to the degenerate range of the matching numeric kind:
- an integer literal
n→Distribution::int(n, n), which is single-valued (the study loop assigns it without sampling); - a float literal
x→Distribution::float(x, x), a degenerate continuous range. This is notis_single_valued(a continuous float reports no cardinality), but its transform pins every unit coordinate tox, so the independent-sampling path returns the constant just the same. A single-valued float would require a fabricated step, which would then have to be invented on every round trip; the degenerate continuous range avoids that and still yields the constant.
Growing declarations¶
parse_growing, parse_schema_growing and parse_schema_lines_growing
are a parallel set of entry points -- parse and its own three callers
are untouched -- that accept everything above plus zero or more trailing
keyword arguments declaring an Open growth policy. render_growing is
the counterpart that renders a distribution back through them, since Open
is deliberately not part of Distribution and Display cannot carry it.
| Form | Adds | Notes |
|---|---|---|
<form>, open |
an Open with Sides::Both |
any form above, plus a bare open |
<form>, open=up |
an Open with Sides::Up |
only the upper bound may grow |
<form>, open=down |
an Open with Sides::Down |
only the lower bound may grow |
<form>, limit=..=<n> |
a high Open::limit |
inclusive; ..<n> (exclusive) is rejected |
<form>, limit=<n>.. |
a low Open::limit |
inclusive; the two limit= forms combine |
around(c, times=<n>) |
Distribution::float_log seed [c/n, c*n], plus an Open |
multiplicative "no idea" declaration |
around(c, plus=<n>) |
Distribution::float seed [c-n, c+n], plus an Open |
additive "no idea" declaration |
open, open=up, open=down and limit=<range> may follow any call
form above (uniform, loguniform, int) or an around(...), in any
combination and any order, after that form's own arguments -- which is why
loguniform(1e-5, 1e-2, open=up, limit=..=1.0) has four arguments where
parse accepts exactly two. around has no closed-form meaning (there is
no plain Distribution for "no idea yet"), so it is recognized only by
parse_growing and always produces a policy, with or without additional
open/limit keywords. limit is independently optional per side, exactly
as Open::limit is; setting the same side twice keeps the later value.
around ... times lowers to a logarithmic seed, which cannot be stepped
(compare Distribution::float_log), so a step= keyword after around
is rejected outright rather than silently accepted.
What reaches text is only the seed, the sides and the limit -- factor
and max_expansions keep their defaults in text and are reachable only from
the Open builder in Rust.
Round trip¶
Distribution implements Display, and
parse(&d.to_string()) reconstructs an equal distribution for every
distribution parse can produce (a property test in this module asserts it
across every kind). The canonical text a valid distribution renders to is
one of the grammar forms above; floats are rendered in the shortest form
that round-trips and always carry float syntax where a bare integer would
otherwise be re-read as an int. Display of a hand-built invalid
distribution is best-effort and is not promised to re-parse.
render_growing extends the same guarantee to a growth policy:
parse_growing(&render_growing(&d, open.as_ref())) reconstructs an equal
(Distribution, Option<Open>) pair for every pair parse_growing can
produce. render_growing(&d, None) renders identically to d.to_string(),
since Open -- not Distribution -- is the only thing it adds.
Whole spaces¶
parse_schema turns an ordered sequence of (name, spec) pairs (a TOML
table, a map, CLI --param pairs) into a SpaceSchema, preserving
declaration order; parse_named parses a single name = spec line; and
parse_schema_lines parses a block of such lines (blank and #-comment
lines are skipped).
parse_schema_growing and parse_schema_lines_growing are the growing
counterparts: each returns a SpacePolicy alongside the SpaceSchema,
holding one Open per parameter whose spec carried a growth keyword. A
spec with none contributes nothing to the policy, so the returned
SpacePolicy only ever names parameters that actually declared themselves
open.
The #[derive(Space)] attributes¶
The same engine reached from a Rust struct instead of a string: one #[space(...)] attribute per field, checked at compile time. atune re-exports the derive, so use atune::prelude::*; is the only import it needs.
Generated from the //! documentation of crates/atune_derive/src/lib.rs, which is the specification the implementation is reviewed against.
The attribute grammar¶
Every field carries exactly one #[space(...)] attribute (a field with no
attribute is a compile error, so nothing is silently dropped). The forms:
| Attribute | Field types | Lowers to |
|---|---|---|
#[space(range = LOW..=HIGH)] |
f32/f64, any integer |
Float / Int |
#[space(log, range = LOW..=HIGH)] |
ditto | logarithmic Float/Int |
#[space(range = LOW..=HIGH, step = S)] |
ditto | stepped Float/Int |
#[space(choices = [A, B, C])] |
any integer, an enum, … | Cat (value ↔ index) |
#[space] |
bool only |
Bool |
#[space(fixed)] |
any | excluded from the schema |
#[space(fixed = EXPR)] |
any | excluded from the schema |
rangeis always inclusive (low..=high). Float ranges take float literals (1e-5..=1e-2), integer ranges take integer literals (1..=16); a mismatched literal is a plain type error from the generated constructor call.logandstepcannot be combined (a logarithmic and uniformly stepped grid is ill-defined — Optuna rejects it too).choicesmaps the listed values to categorical indices in order: the labels stored in the schema are the source text of each choice (64,Optimizer::Adam, …), so an enum is expressed as#[space(choices = [Enum::A, Enum::B])].encodefinds a value's index with==, so a choice field's type must bePartialEq. Automatic enumeration of a bare enum field's variants (the companion#[derive(Categorical)]) is a later slice.fixedis a field that is present on the decoded value but not tuned: it is excluded from the schema and never appears in an encoded point. Ondecodea bare#[space(fixed)]takes the field from the struct'sDefault(so the struct must implementDefault), and#[space(fixed = EXPR)]takes the given expression.
Type mapping¶
| Rust field type | Distribution kind |
|---|---|
f32, f64 |
Float |
i8…i64, u8…u64, isize, usize |
Int |
bool |
Bool |
a choices = [...] list (integers, enum variants) |
Cat |
An integer that does not fit its field type on decode (for example a
stored -1 for a u32 field) is a clean Err, never a panic. encode
is total: an integer field wider than i64 (only u64/usize) saturates
rather than wrapping — a value HPO ranges never reach.
What is a compile error¶
Malformed attributes are rejected at compile time with a spanned
compile_error!, never a panic in the macro:
- an unknown key (
#[space(foo = 1)]); - a non-inclusive range (
#[space(range = 0..1)]); choiceson a floating-point field, orrangeon aboolfield;rangeon a non-numeric field, or a bare non-boolfield with norange/choices/fixed;logandsteptogether, orfixedcombined with any ofrange/choices/log/step;- a field with no
#[space(...)]attribute at all; - a tuple struct, unit struct, enum or union (only named-field structs).
Malformed values (low > high, a logarithmic range with a non-positive
lower bound, a non-positive step, …) are surfaced at run time as an Err
from Space::schema, because they flow through the fallible
Distribution constructors and a library path must not panic.