Skip to content

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) between tokens and around arguments is insignificant; spaces inside a choice label are part of the label. 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::try_new_with_float continuous linear float
uniform(low, high, step) Distribution::try_new_with_stepped_float third arg is the step; step=<v> is also accepted
loguniform(low, high) Distribution::try_new_with_log_float a log range cannot be stepped
int(low, high) Distribution::try_new_with_int unit-step integer
int(low, high, log) Distribution::try_new_with_log_int the bare keyword log
int(low, high, step=<n>) Distribution::try_new_with_stepped_int the step keyword is required for ints
choice(a, b, c) Distribution::try_new_with_categorical_labels labels in order, stable indices
bool Distribution::new_with_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:

  • uniform takes the step as its third positional argument (uniform(0, 1, 0.25)); the explicit uniform(0, 1, step=0.25) is accepted too and means the same thing.
  • int requires the step=<n> keyword (int(2, 512, step=4)), because an int's third slot is shared with the bare log keyword 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::try_new_with_int(n, n), which is single-valued (the study loop assigns it without sampling);
  • a float literal x → Distribution::try_new_with_float(x, x), a degenerate continuous range. This is not is_single_valued (a continuous float reports no cardinality), but its transform pins every unit coordinate to x, 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::try_new_with_log_float seed [c/n, c*n], plus an Open multiplicative "no idea" declaration
around(c, plus=<n>) Distribution::try_new_with_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::try_new_with_log_float), 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 as a human-readable DSL representation. 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). Such output 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 is not lossless serialization for arbitrary hand-built distributions: choice syntax has no escaping, so empty labels, commas, and leading or trailing whitespace cannot round-trip, and typed choice payloads are omitted. Quotes and backslashes are ordinary label characters, not escape syntax. Use serialization that preserves payloads when those must be retained.

render_growing extends the same guarantee to a growth policy: parse_growing(&render_growing(&d, open.as_ref())) reconstructs equal GrowingDistribution fields for every result 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. Import the re-exported derive with use atune::Space;.

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 at least one #[space(...)] attribute (a field with no attribute is a compile error, so nothing is silently dropped). Its options may be split across several attributes, but an option given twice is an error. 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(range = LOW..=HIGH, open)] numeric open on both sides
#[space(range = LOW..=HIGH, open = up)] numeric open above the seed
#[space(range = LOW..=HIGH, open = down)] numeric open below the seed
#[space(range = LOW..=HIGH, open, limit = LOW..=HIGH)] numeric open with inclusive growth limits
#[space(around = CENTRE, times = FACTOR)] f32/f64 multiplicative open seed
#[space(around = CENTRE, plus = DELTA)] f32/f64 additive open seed
#[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
  • range is 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.
  • log and step cannot be combined (a logarithmic and uniformly stepped grid is ill-defined — Optuna rejects it too).
  • choices maps 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])]. encode finds a value's index with ==, so a choice field's type must be PartialEq. If the field contains a value outside the listed choices, encode uses choice index 0; decode rejects an index outside the list. Automatic enumeration of a bare enum field's variants (the companion #[derive(Categorical)]) is not implemented.
  • fixed is 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. On decode a bare #[space(fixed)] takes the field from the struct's Default (so the struct must implement Default), and #[space(fixed = EXPR)] takes the given expression.
  • open opts a numeric range into growth. Bare open grows both sides; open = up or open = down grows only that side. It may accompany step. limit optionally caps either growing side and accepts low.., ..=high, or low..=high; any upper bound must be inclusive. A limit on a side closed by open = up/down is rejected, as is limit without open.
  • around declares a floating-point seed by centre and exactly one spread: times = FACTOR for multiplicative growth or plus = DELTA for additive growth. Bare open is implied; open = up/down and limit refine it. times implies a logarithmic seed, while plus implies a linear seed. around cannot be combined with range, log, or step. times and plus are only valid with around, and cannot both be given.

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.

An f32 field narrows the stored f64 on decode. For a range field, a stored value inside LOW..=HIGH that would round past a bound becomes the adjacent f32 inside it, so the decoded value stays inside the declared range; a value an open range reached by growing past a declared bound narrows unchanged.

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)]);
  • choices on a floating-point field, or range on a bool field;
  • range on a non-numeric field, or a bare non-bool field with no range/choices/fixed;
  • log and step together, or fixed combined with any of range/choices/log/step;
  • open on a bool, choices, or fixed field; limit without open, or a limit on a closed side; an upper limit bound written exclusively;
  • around on a non-float field, without exactly one of times/plus, or combined with range/log/step; times/plus without around;
  • any option repeated, including across multiple field attributes;
  • 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.