Skip to content

Configuration

<easylocal/config/*.hpp> (easylocal::config); TOML in <easylocal/adapters/toml.hpp>

Parameters are plain structs that describe themselves; configurable objects expose them as a parameter set: paths, descriptions and textual values, which frontends (command line, files, TOML, the TextUI) read and change.

Parameter blocks

struct MyParameters
{
    easylocal::parameter<std::size_t, "size"> size{10};  // 10 is its default
    easylocal::parameter<double, "bound"> bound{};       // required: no default
    Schedule schedule{};                                 // a parameter block

    static consteval auto parameter_schema()
    {
        return config::fields(
            config::field<&MyParameters::size>(
                "Description", config::range(1, easylocal::unlimited)),
            config::field<&MyParameters::bound>(
                "Description", config::range(0.0, easylocal::unlimited)),
            config::group<"schedule", &MyParameters::schedule>("Description"));
    }

    config::validation_result validate() const; // success() or failure(reason)
};

Every field is an easylocal::parameter<Value, "name">, which holds the value and whether anything has set it; field<&MyParameters::size> reads the name from the member instead of repeating it. The default of a field is its member initializer, and a field declared bound{} has none: nothing sets it, so validate() reports bound: not set, and it has no default, and reading it before a value arrives throws parameter_not_set. A plain member does not compile: its default would be implicit.

A parameter converts to its value, so it reads like the value it holds (temperature * parameters().cooling_rate); where a template deduces its argument, as (std::max)(a, b) does, or where the value is a class with its own templated operators, as std::string compared with a literal is, value() spells the value out.

The value may be an integral or floating-point type, bool, easylocal::limit, std::string, std::filesystem::path, and std::arrays and std::vectors of those (written [a, b, c]; [] is an empty vector; see Values as text). A group nests another block: its fields are under schedule., and config::check_schema of the enclosing block runs its validate() once the enclosing block's own fields pass, so that the enclosing block's validate() checks it too.

Domains

A field may declare the values it takes as the second argument of field:

config::field<&P::cooling_rate>(
    "Multiplicative cooling factor", config::range(0.0, 1.0).open()),
config::field<&P::samples>("Proposals", config::range(1, 1000).log()),
config::field<&P::policy>("Tabu list", config::one_of("fixed", "random")),
Domain Values
range(low, high) the numbers from low to high, both included, of one type; for a number or a limit
range(low, easylocal::unlimited) the numbers from low up, with no upper bound, its upper end included: infinity for a floating-point field, unlimited for a limit ([1, unlimited]); .open_high() leaves the end out, a finite number or a count ([1, unlimited))
.open(), .open_low(), .open_high() the same range without both bounds, the lower or the upper one
.log() the same range, which a configurator samples on a logarithmic scale; low must be positive
one_of(a, b, ...) the values given: text for a std::string, numbers for a number (numbers of different types are held as their common type: one_of(1, 1.5, 2) holds 1.0, 1.5 and 2.0)
easylocal::unlimited any value of the field's type: a seed, free text, a path, a number that may be negative

The name of a field or group is a segment of its paths: a letter or _, then letters, digits and _. The schema is computed once at compile time, so a mistake in it does not compile, and the error names parameter_schema().

For an array or a vector the domain applies to each element. The library handles NaN and infinity in parameters and costs (a domain rejects NaN, a range with no upper bound includes infinity): -ffast-math, which assumes neither occurs, breaks these checks. A domain that does not fit the field's type, a range whose bounds are not in order or a logarithmic range from zero do not compile, and neither do a range of two types, range(0.0, 1), or a count where a domain goes, field<...>("...", 5): easylocal::unlimited is a tag of its own type, unlimited_t, which converts to the unlimited limit and which no number converts to.

Enumerations

A field may be of an enum type, which a configurator reads and writes by name. C++ cannot read the names of an enum, so they are declared once, beside it:

enum class Start { random, initial, greedy };

template<>
struct easylocal::enumeration<Start>
{
    static constexpr std::array values{
        easylocal::enumerator{"random", Start::random},
        easylocal::enumerator{"initial", Start::initial},
        easylocal::enumerator{"greedy", Start::greedy}};
};

The field is then declared and used like any other, and the values declared are its domain:

easylocal::parameter<Start, "start"> start{Start::random};
// in the schema:
config::field<&Parameters::start>("Where the search starts"),

--start greedy on the command line, start = "greedy" in a configuration file or in TOML, a list in the TextUI, {random, initial, greedy} in --help, and a categorical parameter for irace, c ("random", "initial", "greedy"). A name that is not declared is rejected with expected one of random, initial, greedy, and a value cast from a number that no enumerator has fails validation like any value outside its domain.

config::one_of(Start::random, Start::initial) as the domain of the field keeps only the values it gives, when a field takes fewer than the enum declares. A field of an enum with no easylocal::enumeration does not compile: its values would have no names.

Conditions and requirements

config::field<&P::initial_acceptance>("...", config::range(0.0, 1.0).open())
    .only_if(config::value<"calibration_samples"> > 0),
config::require(
    config::value<"final_temperature"> < config::value<"initial_temperature">,
    "final_temperature must be smaller than initial_temperature"),
Declaration Meaning
field(...).only_if(expression) the field matters only when the expression holds: otherwise its domain is not checked and a configurator does not tune it
require(expression, message) an element of fields(...): the expression must hold, else the block is invalid with message (which must outlive it)
value<"name">, value<"group.name"> a field of the block, or of a nested group, by its path relative to the block; a path that names no field does not compile

Expressions combine values and constants (numbers, bool, text) with <, <=, >, >=, ==, !=, &&, ||, !, +, -, * and /. Numbers are computed in double, as R computes them, so that an expression means the same in its irace export: 7 / 2 is 3.5, a limit is its count and unlimited is +infinity, and -count is negative for an unsigned count. config::evaluate(expression, block) computes one, config::is_active(field, block) tells whether a field's condition holds, and describe_expression(expression, prefix) gives an expression_info, its text with the full paths of its references (text_with, references): in R syntax, booleans are TRUE and FALSE, infinity and NaN Inf and NaN, and a text escapes its quotes and backslashes.

Checking a schema

The validation of a parameter set checks each block against its schema before its validate(): the fields that matter outside their domains, with the field's path (expected a value in (0, 1), got 1.5), and the requirements that do not hold, with the block's path and their message; then each nested group, under its own path; the block's validate() runs only when they pass. A validate() checks the same with config::check_schema, so that a block made in the code is checked too; its reason, the first that fails, has the same text, with the field's name first. A validation_result owns its message, so failure() takes one built at run time as well as a literal:

config::validation_result validate() const
{
    if (const auto schema = config::check_schema(*this); !schema)
        return schema; // "cooling_rate: expected a value in (0, 1), got 2", or a requirement's message
    // ... the checks the schema does not declare
}

config::require_valid(block) returns the block when it passes these checks, its groups included, and throws std::invalid_argument otherwise, the path of the field first (temperature.cooling_rate: expected a value in (0, 1), got 1.5); config::require_valid(set) does the same for a parameter set, with each invalid block as <path>: <message>. The runners, their policies and the neighborhood recipes call it on the parameters they are made from, and an app on its whole configuration when it is bound.

Every field declares a domain: check(app, ...) fails for a parameter of the app without one, and the library's tests for a built-in one; a boolean's domain is true and false. A field that takes any value says so with easylocal::unlimited. config::undeclared_domains(set) lists the paths of the parameters of a set that declare none. Automatic configurators read the domains: a domain is where the values are valid, not necessarily where they are worth trying, and a program can narrow it for tuning; a range with no upper bound, or any value, is not tuned until it is given a finite range.

Parameter sets

config::parameter_set parameters;
parameters.add("application", app_parameters);    // a block
parameters.add("solver", runner.configuration());  // another set, under a prefix
Member Purpose
add([prefix,] block) the fields of a parameter block; read-only when the block is const
add([prefix,] object) an object with parameters() and configure(block) -> validation_result, for objects that rebuild something from their parameters
add([prefix,] set) the parameters of another set
parameters() every parameter: path, description, value (as text), read_only, kind (boolean, integer, real, limit, text, path, list), domain (a domain_info, empty when none is declared), active (whether its condition holds) and condition (an expression_info with full paths, empty when it has none)
requirements() the requirements of every block: path of the block, message, expression (full paths) and satisfied
validate() the fields outside their domains, the requirements that do not hold and the diagnostics of every block's validate(), by path: a parameter_validation_result, true when there are none, with a parameter_diagnostic{path, message} for each
apply(text_overrides) apply path = value overrides (views, or overrides that own their text, as a file gives them), all or none; its result must be read

A set refers to the objects it was built from: they must outlive it and stay in place. Adding a path that is already in the set throws std::invalid_argument.

apply parses each override into a copy of its block and validates the copy, and validates every other block as it is; only when every override names a parameter and every block is valid are the copies committed, so a batch may repair invalid defaults, a requirement of an enclosing block included. load_and_apply applies the file and the command line in one such batch. Errors: unknown_parameter, duplicate_path, parse_error, validation_error (with the path of the invalid block), read_only_parameter.

Configurable objects

One rule makes a class configurable: its parameters_type is a parameter block, and it is constructed from it. The framework holds the parameters in the class's recipe or registration, builds the class from them when a runner or an app is bound (again after they change), and exposes them at the path of the class's role:

Class Constructed as Its parameters given by Path in a runner Path in an app
an algorithm A(parameters) make_runner<A>(parameters), runner<A>("name", parameters), stage<A>("name", parameters) search.* runners.<name>.*; runners.<pipeline>.<stage>.search.* for a stage
a neighborhood explorer NHE(sm, parameters, args...) neighborhood<NHE>(parameters, args...) neighborhood.* neighborhood.*; runners.<name>.neighborhood.* for a runner's own
a SolutionManager SM(input, parameters, args...) solution_manager<SM>(parameters, args...) solution_manager.* solution_manager.*
a cost component C(input, parameters, args...) or C(parameters, args...) component<C>(parameters, args...) cost.<name>.* cost.<name>.*
a cost::apply function F(parameters) cost::apply<F>(parameters, children...) cost.<name>.* cost.<name>.*

Deriving from easylocal::parameters_base<P> declares parameters_type, takes the block in the constructor and gives it back through parameters(); it is optional, and a class that declares them itself is configured just the same.

Without a first argument of the parameters_type, the parameters are the defaults. A component or a function is configured under its name(), which must be static, wherever it is in the cost expression. Only parameters_type is read: a class with a parameters() that returns a parameter block, or a configuration(), and no such parameters_type does not compile, and the message says what to write. An algorithm registered in an app whose parameters_type is not a block still runs, unconfigured, and check(app, ...) reports it as runner parameters.

The structure of the framework's own objects is configurable too: the weights and tolerance of a cost expression, the biases of a neighborhood union, the budgets of pipeline stages. An object gives its parameters when configuration() returns a parameter_set, with paths relative to it; whoever composes it chooses the prefix.

The set refers to the object, so configure the object that will run, after its last copy: configuration() does not compile on a temporary ((make_runner<A>() | sm | nhe).configuration()), whose set would refer to an object already gone.

Provider Paths
a parameterized algorithm (FirstImprovement, BestImprovement, HillClimbing, GreatDeluge, LateAcceptanceHillClimbing, ParetoLateAcceptanceHillClimbing, SimulatedAnnealing, the Tabu Search family), through its runner the fields of its parameters_type; temperature.* for Simulated Annealing, tabu_list.* for Tabu Search
the cost expression of a SolutionManager recipe weights of a cost::sum; children by position (0.*, 1.*), hard.* and soft.* of a cost::hard_soft; tolerance.* of a cost::approximately; each component and cost::apply function with a parameters_type under its name() (<name>.*)
neighborhood_union with random_biases random_biases, and each child's parameters under its position (0.*, 1.*)
neighborhood<NHE>(parameters, args...) for an explorer with parameters_type the explorer's parameters
solution_manager<SM>(parameters, args...) for a SolutionManager with parameters_type the SolutionManager's parameters, as solution_manager.* in a runner or an app
runner.configuration() search.*, cost.*, solution_manager.*, neighborhood.*
an app, app.configuration() (also a Session's) cost.*, solution_manager.*, neighborhood.*, runners.<name>.*
MultiStart, LocalSearch, Pipeline solvers starts and the runner's (MultiStart), the runner's (LocalSearch), each stage's runner and its own attempts, timeout and max_evaluations under its name (Pipeline; first.* and second.* for two_stage()); an algorithm stage of an app's pipeline has its algorithm's search.* and its own neighborhood's, the cost and the SolutionManager being the app's

Values as text

Every frontend reads a value from the same text, and config::format_value writes it back in that syntax:

Type Text
bool true or false
an integer a decimal number within the type's range (no sign for an unsigned type)
a floating-point number a decimal or scientific number, inf, -inf
easylocal::limit a count, or unlimited
std::string, std::filesystem::path the text as it is; a path in UTF-8, on Windows too
an enum with an easylocal::enumeration the name of the value (Enumerations)
std::array, std::vector [a, b, c], the brackets optional; [] is an empty vector, and an array needs exactly its size

Spaces around a value or an element are ignored, except in a text. A list is split at the commas outside nested brackets, [[1, 2], [3, 4]] included, and there is no quoting: a text element of a list cannot contain a comma or a bracket, so format_value and the parser are each other's inverse except for such lists. An error names what was expected, the element of a list by its position (element 2: expected a number), and the size of an array (expected 2 elements, got 3).

Frontends

Function Purpose
config::parse_cli(argc, argv) the overrides --path=value and --path value, --config <file> and --help of a command line, with its errors; the arguments are UTF-8 text, on Windows too
config::parse_config_text(text) the overrides of the text of a configuration file, as load_config_file reads them
config::overlay_overrides(base, top) one batch of two: the overrides of top replace those of base with the same path
config::require_valid(set) throws std::invalid_argument unless every block is valid
config::undeclared_domains(set) the paths of the parameters that declare no domain
config::load_and_apply(argc, argv, parameters[, read_config]) apply --config <file> and --path.to.field=value; the file is read by read_config, a config_file_reader, load_config_file by default, load_toml_file for a TOML file
config::load_config_file(path) the overrides of a file of path = value lines: # starts a whole-line comment (a # after a value is part of it), a UTF-8 byte order mark is skipped, a directory is an error
config::cli_help(program, parameters) help text: each parameter that can be changed, with its description, its values (the domain; true or false for a boolean), when it matters (only if ...) and its current value
config::print_diagnostics(out, result) report the errors of load_and_apply, or of a config_file_parse_result, one per line, those of a file with their line and column
config::format_value(value) a value as text, in the syntax overrides use
config::load_toml_file(path) (TOML adapter) a config_file_reader: the overrides of a TOML file, each value by its TOML type (a boolean, an integer, a float that sets only a floating-point field, a string, or an array of numbers, booleans and such arrays; not of strings), as a config_file_parse_result; a parse_error gives its line and column and the file as its text, an unsupported_value its path

Design choices

  • Configuration is independent of construction. A set refers to live objects; nothing is rebuilt from a parameter box, and binding a runner later sees the applied values.
  • Typed fields, no reflection macros. Schemas are consteval descriptions of member pointers, checked at compile time; only the paths are matched at run time, as the command line and files already require.
  • Relative paths. Components do not choose their own top-level names, so one program can combine several of them under prefixes of its choice.
  • Validation lives with the parameters, so every frontend reports the same errors.