Parameters¶
Every slider in the app and every --set override in the CLI reads from a single declaration list on the model.
You write the id, label, range and default together in one place, and both front ends work from those declarations, leaving no second list to keep in step.
This page covers the declaration API, when an edit applies live, when it needs a rebuild, the way the indices compose, and the actions a model offers beside its parameters.
henad_core::params! {
const VISUAL_RANGE = f32_param("visual_range", "Visual Range", 50.0, 1.0, 200.0, Some(1.0));
const PROTECTED_RANGE = f32_param("protected_range", "Protected Range", 8.0, 0.5, 50.0, Some(0.5));
const SEPARATION = f32_param("separation", "Separation", 0.05, 0.0, 2.0, Some(0.01));
const ALIGNMENT = f32_param("alignment", "Alignment", 0.05, 0.0, 2.0, Some(0.01));
const COHESION = f32_param("cohesion", "Cohesion", 0.0005, 0.0, 0.01, Some(0.0001));
const MAX_SPEED = f32_param("max_speed", "Max Speed", 15.0, 1.0, 50.0, Some(0.5));
const MIN_SPEED = f32_param("min_speed", "Min Speed", 3.0, 0.5, 20.0, Some(0.5));
}
The params! macro expands to one const per entry, holding an index derived from the entry's position in the declaration, together with a descriptors() function that returns the whole list.
The example models sit below the facade, in henad-models, and use henad_core::params!.
A project uses henad::params!, as the template's src/vote.rs does.
Your impl forwards param_descriptors to descriptors, and reads values back through the generated index constants.
Kinds¶
pub enum ParamKind {
F32 { min: f32, max: f32, default: f32, step: Option<f32> },
U32 { min: u32, max: u32, default: u32 },
Bool { default: bool },
Choice { options: &'static [&'static str], default: usize },
}
For the common cases there are the f32_param and u32_param helpers.
Both take an id, a label, a default, a minimum and a maximum, and f32_param additionally takes a slider step.
The id is the machine-facing name, matched by henad-cli --set and recorded in the benchmark CSV output.
The label is the human-facing name the app shows.
bool_param and choice_param cover the other two kinds.
bool_param takes an id, a label and a default, and choice_param takes an id, a label, the option labels and the index of the default option.
Virus on a Network declares all four kinds.
henad_core::params! {
const AVERAGE_NODE_DEGREE = u32_param("average_node_degree", "Average Node Degree", 6, 1, 20).on_reload();
const INITIAL_OUTBREAK_SIZE =
u32_param("initial_outbreak_size", "Initial Outbreak Size", 3, 1, 10_000).on_reload();
const VIRUS_SPREAD_CHANCE =
f32_param("virus_spread_chance", "Virus Spread Chance", 0.025, 0.0, 1.0, Some(0.001)).percent();
const VIRUS_CHECK_FREQUENCY = u32_param("virus_check_frequency", "Virus Check Frequency", 1, 1, 20);
const RECOVERY_CHANCE = f32_param("recovery_chance", "Recovery Chance", 0.05, 0.0, 1.0, Some(0.001)).percent();
const GAIN_RESISTANCE_CHANCE =
f32_param("gain_resistance_chance", "Gain Resistance Chance", 0.05, 0.0, 1.0, Some(0.01)).percent();
const DIRECTED = bool_param("directed", "Directed", false);
const NETWORK = choice_param("network", "Network", NETWORKS, RANDOM).on_reload();
const KEEP_REWIRING = bool_param("keep_rewiring", "Keep Rewiring", false);
}
NETWORKS is &["Random", "Geometric"], and RANDOM is 0.
The Parameters tab draws a number as a slider, a bool as a checkbox and a choice as a dropdown.
Each kind has a matching reader, extract_f32, extract_u32, extract_bool or extract_choice.
All four readers take the slice, an index and a fallback, and return the fallback when that index is missing or holds another kind.
extract_choice returns the index of the chosen option.
.percent() sets a parameter's format to ParamFormat::Percent, for a fraction shown as a percentage.
The stored value stays a fraction in 0..=1, and only the Parameters tab scales it.
Virus Spread Chance holds 0.025 and shows as 2.5%.
--set accepts the fraction too, and --params marks the parameter format=percent.
Live against reload¶
A live parameter takes effect on the next tick. Anything marked reload is read once while the state is being built, and changing it means rebuilding the state.
Live is the default, and calling .on_reload() on a declaration flips it.
Both front ends read this behaviour from the descriptor, so you declare it in exactly one place.
ParamStore::set rejects an edit to anything declared OnReload and returns whether the edit was applied.
The app reads the same flag and can say so before anything is sent.
Declare .on_reload() for any parameter that only init reads.
Otherwise a live edit to it changes nothing, with no sign that it failed.
The testing kit's ApplyModes check builds the model, edits every parameter, and asserts that the state accepts exactly the edits the descriptor says it will.
Hot parameters¶
Your kernel receives a &Self::Params rather than the raw &[ParamValue] slice.
Matching an enum per cell or per agent would put the match inside the inner loop, so from_params runs once at the start of each tick instead, and every invocation within that tick shares its result.
fn from_params(params: &[ParamValue]) -> SirParams {
SirParams {
infection_rate: extract_f32(params, INFECTION_RATE, 0.3),
recovery_rate: extract_f32(params, RECOVERY_RATE, 0.05),
}
}
This is also the place for anything the kernel would otherwise recompute on every invocation. Boids precomputes squared ranges and half extents here, which leaves the neighbour loop with no per-neighbour setup.
A model with nothing to extract uses type Params = () and an empty from_params.
Indices¶
The engine prepends the parameters that every model of a given topology needs.
Your from_params receives its own 0-based slice, never the composed list.
The split between the slices comes from the descriptor lengths rather than a hard-coded number, which keeps a model or a field layer from shifting the other's indices when it gains a parameter.
Both GPU traits drop the prefix entirely.
Nothing is prepended and the model spells its whole list out, letting a GPU port mirror the exact parameter order of the CPU model it is compared against.
In practice the two GPU agent ports reuse their counterpart's composed list verbatim, by calling agent_model_param_descriptors.
The two GPU grid ports spell their lists out, with the same ids in the same order.
Reading them¶
--params prints every id, kind, default and range for a model.
--set id=value overrides one value, and the flag can be repeated.
A bool accepts true or false, and a choice accepts an option label or its index.
--set network=Geometric and --set network=1 pick the same option.
See the command line for the full CLI, and the models for what every shipped model declares.
Actions¶
An action is a one-off change to the state that the user requests between ticks, such as Game of Life's Randomise and Clear.
You declare actions next to the parameters, with the actions! macro, which a project uses as henad::actions!.
henad_core::actions! {
const RANDOMISE = ActionDescriptor::new("randomise", "Randomise");
const CLEAR = ActionDescriptor::new("clear", "Clear");
}
An ActionDescriptor holds an id and a label.
The id is the name henad-cli --act matches, and the label is the text on the button.
Like params!, the macro expands to one const per entry holding its index, along with an ACTION_SPECS slice for your impl to forward to ACTIONS.
A model with no actions leaves ACTIONS at its default, the empty slice.
When the user presses a button, the engine calls act with that entry's index, and you match on the generated constants.
/// Randomise runs `init` again, at the density the model was built with.
fn act(action: usize, grid: &mut Grid2D<u8>, params: &[ParamValue], rng: &mut u64) {
match action {
RANDOMISE => Self::init(grid, params, rng),
CLEAR => grid.current_mut().fill(DEAD),
_ => {}
}
}
act takes the index plus what init takes, and on an agent model the field as well.
fn act(action: usize, nodes: &mut Nodes<'_, Self>, extent: Extent, params: &[ParamValue], rng: &mut u64);
nodes holds the lanes, the graph and the model's Aux, the same as in init.
An action can change the graph through it.
Virus on a Network's Rewire a link moves one random edge to a pair of nodes not yet joined.
params is the model's own slice of the values the state holds.
A live parameter reads its current value there, and a reload parameter reads the value the state was built with.
Game of Life's Initial Density is a reload parameter, and Randomise refills at the density the model was built with, even after the slider has moved.
rng is its own stream, separate from the stream that the ticks draw from.
The engine seeds it from the state's seed with action_seed, and each press carries on where the last one stopped.
Pressing twice draws twice, and a press leaves the next tick's draws where they were.
The engine runs an action between two ticks and publishes a snapshot straight after it.
The result shows even while the simulation is paused.
The Parameters tab draws one button per action under the parameter widgets, disabled until the selected model is built.
henad-cli --act ID@TICK runs an action when the state reaches that tick, and the command line covers the flag.
The testing kit's Actions check presses every declared action on a freshly built state, and asserts that the state accepts each one and rejects an index past the last.
Its ActionIds check asserts that no two actions of a model share an id, since --act could not tell them apart.
It also rejects an id that is empty or holds whitespace or =.
Neither --vary action.NAME=LEVELS nor a design table can refer to such an id.
ParamIds applies the same rule to a parameter id, and also rejects a parameter id that starts with action..
Actions on the GPU¶
On the GPU an action is its own compute pass, dispatched once per press.
A GpuGridAction holds the descriptor, a shader and that shader's bindings.
The pass is dispatched over step_dims like a step, and its uniform block comes from action_params_bytes.
By default that is the step's block, enough for an action that needs nothing but the dimensions.
A GpuAgentAction holds the descriptor and a PassSpec, dispatched once over the pass's own domain.
The engine requests its uniform block from pass_params_bytes with PassId::Action(i), where i is the action's index.
PassCtx::seed carries the seed.
Every other pass sees a seed of zero.
The seed is fresh on every press.
A shader that needs random numbers derives them by hashing the seed.
GPU SIR's Seed outbreak draws from it instead of from the per-cell rng buffer, and the run's own stream stays where it was.
An action pass writes the model's buffers in place. Nothing swaps after it, so its write bindings resolve to the side that holds the current state, the same side its read bindings see. Its bindings count towards the model's storage-buffer demand like those of any other pass.
A GPU action need not reproduce its CPU counterpart. GPU Game of Life's Randomise hashes one draw per cell where the CPU model walks a stream, and the two actions refill the grid differently.
Next¶
- Statistics covers the stat series, which a model declares in much the same way.
- Writing fast models explains the reasoning behind the hot-parameter split.