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

  • 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::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 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::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
  • 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. Automatic enumeration of a bare enum field's variants (the companion #[derive(Categorical)]) is a later slice.
  • 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.

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)]);
  • 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;
  • 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.