Your own project¶
Your own project holds your models, and runs them in its own app, command line and web build, on the Henad crates published to crates.io.
It starts as a copy of the template in Henad's repository, templates/model-project.
Henad 0.3
This page describes Henad 0.3.
Getting the template¶
Fetch the template from the release it belongs to.
Its Cargo.toml then requires that release's crates.
mkdir my-model && cd my-model
curl -L https://github.com/micfong-z/henad/archive/refs/tags/v0.3.0.tar.gz \
| tar -xzf - --strip-components=3 henad-0.3.0/templates/model-project
git init
On Windows, the commands on this page run in Git Bash or WSL.
The project starts with two models of the voting rule, vote on the CPU and gpu_vote, its port to the GPU.
The first-model tutorials add models beside them, and you can delete vote and gpu_vote once you have your own models.
gpu_vote can be deleted on its own, while vote can only be deleted together with gpu_vote, since the port seeds itself by calling Vote::init.
Renaming it¶
Renaming the package renames the library too, and the my_model:: paths in src/main.rs and src/bin/my-model-cli.rs have to be edited to the new name.
A full rebrand renames these:
- the package name in
Cargo.toml, and themy_model::models()paths insrc/main.rsandsrc/bin/my-model-cli.rs, - the two
[[bin]]names,default-run, and the CLI binary's file, data-binand<title>inindex.html,cli_commandinsrc/main.rs, andcommand_nameandaboutin the CLI binary,- the product name passed to
AppOptions::new.
The product name is the app's window title, and the folder where the native app keeps its settings is named after it.
"My Model" keeps them in My-Model under ~/Library/Application Support on macOS, in mymodel under $XDG_DATA_HOME or ~/.local/share on Linux, and in My Model\data under %APPDATA% on Windows.
What is in it¶
my-model/
├── Cargo.toml
├── README.md
├── build.rs # shader bindings and the commit stamp
├── rust-toolchain.toml # the stable toolchain Henad pins, with the wasm32 target
├── rustfmt.toml # the 120 columns Henad formats at
├── .gitignore
├── .gitattributes # LF line ends for the scripts and the pins
├── .cargo/config.toml # the wasm32 flags of the threaded web build
├── index.html # the web page, for Trunk
├── Trunk.toml # file names, the two headers and the port of the web build
├── assets/icon-256.png
├── specs/vote.toml # a small sweep over the initial density
├── scripts/
│ ├── build_web.sh # the web build, on the dated nightly
│ ├── ci.sh # the stages CI runs
│ ├── install-lavapipe.sh # a GPU driver that runs on the CPU, for CI
│ ├── trunk-sha256 # the SHA-256 of the Trunk tarball CI installs, one line
│ ├── trunk-version # the Trunk release, one line
│ ├── web-checks.sh # the checks both web stages run first
│ └── web-toolchain # the dated nightly, one line
├── src/
│ ├── lib.rs # models(), and the test of every model in it
│ ├── main.rs # the app, natively and on the web
│ ├── bin/my-model-cli.rs # the command line
│ ├── vote.rs # a CPU model
│ └── gpu_vote/ # its GPU port: mod.rs and three shaders
└── .github/
├── workflows/ci.yml # runs scripts/ci.sh
└── dependabot.yml # keeps the dependencies current
src/lib.rs is the library, and its models() is the one place a model is registered.
The app, the command line and the test all read their models from it.
//! Models of the my-model project.
// Proving a type that holds wgpu handles `Send` or `Sync` walks wgpu-core's registries, deeper than the default
// limit of 128.
#![recursion_limit = "256"]
mod gpu_vote;
mod vote;
henad::include_shaders!();
use henad::authoring::register_gpu_grid_model;
use henad::authoring::register_grid_model;
/// Returns every model this crate provides.
///
/// # Errors
///
/// Returns [`henad::ModelSetError`] when two models share an id or an id breaks the id grammar.
pub fn models() -> Result<henad::ModelSet, henad::ModelSetError> {
let mut models = henad::ModelSet::new(henad::build_info!());
models.insert(register_grid_model::<vote::Vote>())?;
models.insert(register_gpu_grid_model::<gpu_vote::GpuVote>())?;
Ok(models)
}
#[cfg(test)]
mod tests {
use henad::testing::{CheckSettings, TestDeviceRequest, assert_set_conforms, headless_test_device};
#[test]
fn every_model_conforms() {
let models = super::models().expect("model ids are unique");
let mut settings = CheckSettings::default();
if let Some(device) = headless_test_device(&TestDeviceRequest::baseline()) {
settings = settings.gpu(device);
}
assert_set_conforms(&models, &settings);
}
}
The .gitignore lists /target and /dist, the two folders that a build writes, and the files that Finder, editors, merges and patches leave beside sources.
It never lists Cargo.lock.
A project that keeps sweep folders inside its tree adds /runs to it.
Running it¶
cargo run --release # the app
cargo run --release -- --open runs/vote # the app, on a results folder
cargo run --release --bin my-model-cli -- vote --steps 500 --reps 3
cargo run --release --bin my-model-cli -- --spec specs/vote.toml --out runs/vote
Run a model with --release.
The dev profile builds the crate holding your kernels at opt-level 1, with overflow checks and debug assertions on, and a model steps several times slower there.
A toy kernel at opt-level 1 ran about 4.7 times slower than at 2, in single runs.
A plain cargo run --bin my-model-cli -- vote --params is enough for the commands that step nothing.
The command line has every mode of henad-cli, sweeps, searches, shards and exit codes included.
The CLI reference lists them under the name henad-cli, and in your project the binary is my-model-cli.
The lock file¶
The template ships no Cargo.lock.
The first build writes one, and you commit it.
A sweep split into shards, or resumed later, then runs the same dependencies on every machine and every day it runs, as long as each build passes --locked:
cargo run --release --locked --bin my-model-cli -- --spec specs/vote.toml --shard 0/4 --out runs/vote-0
A resume or a merge warns when the recorded builds differ, and resuming a sweep says what the warning compares.
Build profiles¶
Cargo ignores a dependency's profiles, and the published crates carry no profiles.
Henad's kernels are generic, and they compile in the crate that registers a model, at that crate's opt-level.
The template's Cargo.toml sets the profiles Henad measures at:
# The kernels expand in this crate, through `agent_lanes!` and the engine generics, at this crate's opt-level.
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 2
[profile.release]
opt-level = 2
Without this block a debug build runs your kernels at opt-level 0, and a release build at 3. Every Henad number is measured at 2. Keep it when you copy the dependencies into another project.
The build script¶
//! Generates bindings for the shaders under `src`, and stamps the build of this crate's models.
fn main() -> Result<(), henad_build::ShaderBuildError> {
henad_build::stamp_commit();
henad_build::ShaderBuild::discover("src")?.generate()?;
Ok(())
}
ShaderBuild::discover("src") generates Rust bindings for every shader under src.
A GPU model added later joins them on the next build, and no build file changes.
Shaders and bindings covers the bindings.
stamp_commit records the commit the crate was built from, whether its sources differed from it, and a hash of the sources.
Every results folder records that build beside each model.
A resume or a merge compares it with the build that runs it and warns when they differ, and the app warns before it opens a run of another build.
Without the stamp the build is treated as unknown, and an uncommitted edit to a model goes unrecorded.
Two unknown builds never count as the same, and every resume warns whether or not the model changed.
A crate without shaders drops the ShaderBuild line and henad::include_shaders!(), and keeps build.rs for the stamp.
The stamp hashes the files under src and the manifest.
A file that a model reads at compile time, through include_bytes! or include_str!, belongs under src, or a change to it goes unrecorded.
The stamp follows no symlink to a folder, and sees no file outside src that a shader imports.
A change to a shader there reaches the bindings and goes unrecorded.
The app's icon sits in assets/, since it changes no result.
A commit reruns the build script, even one that changes no source, once the project sits in a git repository.
The crate holding your kernels then recompiles, and both binaries relink.
Henad's own crates stay built.
A project built before git init records no commit until a file under src or the manifest changes, or cargo clean -p my-model runs.
Updating Henad¶
cargo update -p henad # moves henad, henad-build and the crates between them together
cargo update # every dependency
Never update henad-build alone with cargo update -p henad-build, which leaves the engine behind it.
A new minor release, such as 0.3 to 0.4, changes the henad and henad-build requirements in Cargo.toml together.
Dependabot opens one pull request for both crates.
Henad's crates require each other at the same version, and cargo update -p henad moves them all.
A new minor release can change the API, and the CHANGELOG lists each change with what to do about it. Releasing describes what a release may change.
The web build¶
scripts/build_web.sh serve --release # serves on http://127.0.0.1:8081
scripts/build_web.sh build --release # writes dist/
The web build runs a thread pool in the browser, and needs a nightly toolchain with a standard library rebuilt for it.
The script reads the dated nightly from scripts/web-toolchain, and prints the command that installs it when it is missing:
rustup toolchain install "$(cat scripts/web-toolchain)" --profile minimal \
--component rust-src,clippy --target wasm32-unknown-unknown
Trunk is the other tool it needs, at the release that scripts/trunk-version specifies, and the script prints this command as well when Trunk is missing:
The wasm32 flags live in .cargo/config.toml, and any flag you add for the web goes into its array.
An exported RUSTFLAGS or CARGO_ENCODED_RUSTFLAGS would replace that array without notice, and the build would fail on an error that mentions neither variable.
The script exits with an error while either variable is set, even to an empty value, and so does the lint-web stage of scripts/ci.sh.
A host serving dist/ has to send two headers:
Without them the app runs on one thread in Chrome, and does not start in a browser that blocks shared memory.
GitHub Pages cannot send them, and a site there needs a service worker such as coi-serviceworker to add them.
A project site on GitHub Pages sits under /<repo>/, and builds with scripts/build_web.sh build --release --public-url /<repo>/.
A browser keeps one web app's saved settings per origin.
Serve each app from its own origin.
Checks and CI¶
scripts/ci.sh runs the stage given as its argument, or all four stages when it has no argument:
| Stage | Runs |
|---|---|
lint |
Clippy over the package, warnings denied |
lint-web |
Clippy for the threaded web build, on the nightly |
test |
The tests, the check of every model in models() among them |
web |
The release web build, then a check that dist/ holds the worker glue |
Each stage passes --locked once a Cargo.lock exists.
.github/workflows/ci.yml runs them on every push to main, on every pull request, and when started by hand.
It runs each script through bash, since a project committed from Windows records no executable bits.
On Windows, git add --chmod=+x scripts/*.sh records them for collaborators on Linux and macOS.
The test at the foot of src/lib.rs runs Henad's testing kit over every model in models(), and a model added there is checked by the next cargo test.
A runner on GitHub has no GPU, and the workflow installs lavapipe, a Vulkan driver that runs on the CPU, through scripts/install-lavapipe.sh.
It sets HENAD_REQUIRE_GPU=1, and a GPU check that would be skipped fails instead.
Lavapipe has no watchdog.
Run cargo test on your own machine's GPU as well.
cargo deny reports two crates as unmaintained.
fxhash, under RUSTSEC-2025-0057, reaches your project as a build dependency of henad-build's shader generator, and never runs in a binary.
paste, under RUSTSEC-2024-0436, is a procedural macro that henad-app's egui_dock uses, and goes with the app feature.
Henad's own deny.toml ignores both advisories with these reasons.
Publishing a model library¶
The template is already the shape of a library: the app and the command line sit behind the package's own features app and cli, both on by default.
To publish your models as a crate:
- Remove
publish = falsefromCargo.toml. - Set
licenseanddescription, and anexcludelist for what only the project needs:index.html,scripts/,.cargo/andspecs/. - Keep the
appandclifeatures, so that a consumer can turn both off. - Keep
build.rsand itsstamp_commit()line.
A consumer depends on the library with its features off, and adds the models to its own set:
Each entry keeps your crate's name and version as its source, and a consumer's results folder records them.
Without default-features = false, every consumer would compile the app and the command line, since Cargo merges the features that every dependent requests.
A model library is compatible with one Henad 0.x at a time, and moves to the next 0.x release together with Henad.
Next¶
- Writing a CPU grid model adds a model to the project, step by step.
- Model sets covers
models()and the entries in it. - Testing your model covers the checks the template's test runs.