Introduction
Ixa (Interactive eXecution of ABMs) is a Rust framework for building modular agent-based discrete event models for large-scale simulations. While its primary application is modeling disease transmission, its flexible design makes it suitable for a wide range of simulation scenarios.
Ixa is named after the Ixa crab, a genus of Indo-Pacific pebble crabs from the family Leucosiidae.
You are reading The Ixa Book, a tutorial introduction to Ixa. API documentation can be found at https://ixa.rs/doc/ixa.
This book assumes you have a basic familiarity with the command line and at least some experience with programming.
Get Started
If you are new to Rust, we suggest taking some time to learn the parts of Rust that are most useful for ixa development. We've compiled some resources in rust-resources.md.
Execute the following commands to create a new Rust project called ixa_model.
cargo new --bin ixa_model
cd ixa_model
Use Ixa's new project setup script to setup the project for Ixa.
curl -s https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh -s
Open src/main.rs in your favorite editor or IDE to verify the model looks like
the following:
use ixa::run_with_args;
fn main() {
run_with_args(|context, _, _| {
context.add_plan(1.0, |context| {
println!("The current time is {}", context.get_current_time());
});
Ok(())
})
.unwrap();
}
To run the model:
cargo run
# The current time is 1
To run with logging enabled globally:
cargo run -- --log-level=trace
To run with logging enabled for just ixa_model:
cargo run -- --log-level=ixa_model:trace
Command Line Usage
This document contains the help content for the ixa command-line program.
ixa
Default cli arguments for ixa runner
Usage: ixa [OPTIONS]
Options:
-
-r,--random-seed <RANDOM_SEED>— Random seedDefault value:
0 -
-c,--config— Optional path for a global properties config file -
-o,--output <OUTPUT_DIR>— Optional path for report output -
--prefix <FILE_PREFIX>— Optional prefix for report files -
-f,--force-overwrite— Overwrite existing report files? -
-l,--log-level <LOG_LEVEL>— Enable logging -
-v,--verbose— Increase logging verbosity (-v, -vv, -vvv, etc.)Level ERROR WARN INFO DEBUG TRACE Default ✓ -v ✓ ✓ ✓ -vv ✓ ✓ ✓ ✓ -vvv ✓ ✓ ✓ ✓ ✓ -
--warn— Set logging to WARN level. Shortcut for--log-level warn -
--debug— Set logging to DEBUG level. Shortcut for--log-level DEBUG -
--trace— Set logging to TRACE level. Shortcut for--log-level TRACE -
-d,--debugger— Set a breakpoint at a given time and start the debugger. Defaults to t=0.0 -
-w,--web— Enable the Web API at a given time. Defaults to t=0.0 -
-t,--timeline-progress-max <TIMELINE_PROGRESS_MAX>— Enable the timeline progress bar with a maximum time -
--no-stats— Suppresses the printout of summary statistics at the end of the simulation
Your First Model
In this section we will get acquainted with the basic features of Ixa by implementing a simple infectious disease transmission model. This section is just a starting point. It is not intended to be:
- An introduction to the Rust programming language (or crash course) or software engineering topics like source control with Git
- A tutorial on using a Unix-flavored command line
- An overview or survey of either disease modeling or agent-based modeling
- An exhaustive treatment of all of the features of Ixa
Our Abstract Model
We introduce modeling in Ixa by implementing a simple model for a food-borne illness where infection events follow a Poisson process. We assume that each susceptible person has a constant risk of becoming infected over time, independent of past infections. The Poisson process describes events (infections) occurring randomly in time but with a constant rate.
In this model, each individual susceptible person has an exponentially distributed time until they are infected. This means the time between successive infection events follows an exponential distribution. The rate of infection is typically expressed as a force of infection, which is a measure of the risk of a susceptible individual contracting the disease. In the case of a food-borne illness, this force is constant, meaning each susceptible individual faces a fixed probability per unit time of becoming infected, independent of the number of people already infected. Infected individuals subsequently recovery and cannot be re-infected. (Note that while this model has S, I, and R compartments, it is different from the canonical "SIR" model. In our simple model, the force of infection does not depend on the prevalence of infected persons. Put another way, our "I" compartment consists merely of infected persons; they are not infectious.)
High-level view of how Ixa functions
This diagram gives a high-level view of how Ixa works:
Don't expect to understand everything in this diagram straight away. The major concepts we need to understand about models in Ixa are:
Context: AContextkeeps track of the state of the world for our model and is the primary way code interacts with anything in the running model.- Timeline: A future event list of the simulation, the timeline is a queue
of
Callbackobjects -called plans - that will assume control of theContextat a future point in time and execute the logic in the plan. - Plan: A piece of logic scheduled to execute at a certain time on the
timeline. Plans are added to the timeline through the
Context. - Agents: Generally people in a disease model, agents are the entities that dynamically interact over the course of the simulation. Data can be associated with agents as properties—"people properties" in our case.
- Property: Data attached to an agent.
- Module: An organizational unit of functionality. Simulations are
constructed out of a series of interacting modules that take turns
manipulating the Context through a mutable reference. Modules store data in
the simulation using the
DataPlugintrait that allows them to retrieve data by type. - Event: Modules can also emit 'events' that other modules can subscribe to handle by event type. This allows modules to broadcast that specific things have occurred and have other modules take turns reacting to these occurrences. An example of an event might be a person becoming infected by a disease.
The organization of a model's implementation
A model in Ixa is a computer program written in the Rust programming language that uses the Ixa library (or "crate" in the language of Rust). A model is organized into of a set of modules that work together to provide all of the functions of the simulation. For instance, a simple disease transmission model might consist of the following modules:
- A population loader that initializes the set of people represented by the simulation.
- A transmission manager that models the process of how a susceptible person in the population becomes infected.
- An infection manager that transitions infected people through stages of disease until recovery.
- A reporting module that records data about how the disease evolves through the population to a file for later analysis.
The single responsibility principle in software engineering is a key idea behind modularity. It states that each module should have one clear purpose or responsibility. By designing each module to perform a single task (for example, loading the population data, managing the transmission of the disease, or handling infection progression), you create a system where each part is easier to understand, test, and maintain. This not only helps prevent errors but also allows us to iterate and improve each component independently.
In the context of our disease transmission model:
- The population loader is solely responsible for setting up the initial state of the simulation by importing and structuring the data about people.
- The transmission manager focuses exclusively on modeling the process by which persons get infected.
- The infection manager takes care of the progression of the disease within an infected individual until recovery.
- The reporting module handles data collection and output, ensuring that results are recorded accurately.
By organizing the model into these distinct modules, each with a single responsibility, we ensure that our simulation remains organized and manageable—even as the complexity of the model grows.
The rest of this chapter develops each of the modules of our model one-by-one.
Setting Up Your First Model
Create a new project with Cargo
Let's setup the bare bones skeleton of our first model. First decide where your
Ixa-related code is going to live on your computer. On my computer, that's the
Code directory in my home folder (or ~ for short). I will use my directory
structure for illustration purposes in this section. Just modify the commands
for wherever you chose to store your models.
Navigate to the directory you have chosen for your models and then use Cargoto
initialize a new Rust project called disease_model.
cd ~/Code
cargo new --bin disease_model
Cargo creates a directory named disease_model with a project skeleton for us.
Open the newly created disease_model directory in your favorite IDE, like
VSCode (free) or
RustRover.
🏠 home/
└── 🗂️ Code/
└── 🗂️ disease_model/
├── 🗂️ src/
│ └── 📄 main.rs
├── .gitignore
└── 📄 Cargo.toml
source control
The .gitignore file lists all the files and directories you don't want to
include in source control. For a Rust project you should at least have
target and Cargo.lock listed in the .gitignore. I also make a habit of
listing .vscode and .idea, the directories VS Code and JetBrains
respectively store IDE project settings.
cargo
Cargo is Rust's package manager and build system. It is a single tool that
plays the role of the multiple different tools you would use in other
languages, such as pip and poetry in the Python ecosystem. We use Cargo to
- install tools like ripgrep (
cargo install) - initialize new projects (
cargo newandcargo init) - add new project dependencies (
cargo add serde) - update dependency versions (
cargo update) - check the project's code for errors (
cargo check) - download and build the correct dependencies with the correct feature flags
(
cargo build) - build the project's targets, including examples and tests (
cargo build) - generate documentation (
cargo doc) - run tests and benchmarks (
cargo test,cargo bench)
Setup Dependencies and Cargo.toml
Ixa comes with a convenience script for setting up new Ixa projects. Change
directory to disease_model/, the project root, and run this command.
curl -s https://raw.githubusercontent.com/CDCgov/ixa/release/scripts/setup_new_ixa_project.sh | sh -s
The script adds the Ixa library as a project dependency and provides you with a
minimal Ixa program in src/main.rs.
Dependencies
We will depend on a few external libraries in addition to Ixa. The cargo add
command makes this easy.
cargo add rand_distr@0.4.3
cargo add csv
Notice that:
- a particular version can be specified with the
packagename@1.2.3syntax; - we can compile a library with specific features turn on or off.
Cargo.toml
Cargo stores information about these dependencies in the Cargo.toml file. This
file also stores metadata about your project used when publishing your project
to Crates.io. Even though we won't be publishing the crate to Crates.io, it's a
good idea to get into the habit of adding at least the author(s) and a brief
description of the project.
# Cargo.toml
[package]
name = "disease_model"
description = "A basic disease model using the Ixa agent-based modeling framework"
authors = ["John Doe <jdoe@example.com>"]
version = "0.1.0"
edition = "2024"
publish = false # Do not publish to the Crates.io registry
[dependencies]
csv = "1.3.1"
ixa = { git = "https://github.com/CDCgov/ixa", branch = "main" }
rand_distr = "0.5.1"
serde = { version = "1.0.217", features = ["derive"] }
Executing the Ixa model
We are almost ready to execute our first model. Edit src/main.rs to look like
this:
// main.rs
use ixa::run_with_args;
fn main() {
run_with_args(|context, _, _| {
context.add_plan(1.0, |context| {
println!("The current time is {}", context.get_current_time());
});
Ok(())
})
.unwrap();
}
Don't let this code intimidate you—it's really quite simple. The first line says
we want to use symbols from the ixa library in the code that follows. In
main(), the first thing we do is call run_with_args(). The run_with_args()
function takes as an argument a closure inside which we can do additional setup
before the simulation is kicked off if necessary. The only setup we do is
schedule a plan at time 1.0. The plan is itself another closure that prints the
current simulation time.
closures
A closure is a small, self-contained block of code that can be passed around and executed later. It can capture and use variables from its surrounding environment, which makes it useful for things like callbacks, event handlers, or any situation where you want to define some logic on the fly and run it at a later time. In simple terms, a closure is like a mini anonymous function.
The run_with_args() function does the following:
-
It sets up a
Contextobject for us, parsing and applying any command line arguments and initializing subsystems accordingly. AContextkeeps track of the state of the world for our model and is the primary way code interacts with anything in the running model. -
It executes our closure, passing it a mutable reference to
contextso we can do any additional setup. -
Finally, it kicks off the simulation by executing
context.execute(). Of course, our model doesn't actually do anything or even contain any data, socontext.execute()checks that there is no work to do and immediately returns.
If there is an error at any stage, run_with_args() will return an error
result. The Rust compiler will complain if we do not handle the returned result,
either by checking for the error or explicitly opting out of the check, which
encourages us to do the responsible thing: match result checks for the error.
We can build and run our model from the command line using Cargo:
cargo run
Enabling Logging
The model doesn't do anything yet—it doesn't even emit the log messages we included. We can turn those on to see what is happening inside our model during development with the following command line argument:
cargo run -- --log-level trace
This turns on messages emitted by Ixa itself, too. If you only want to see
messages emitted by disease_model, you can specify the module in addition to
the log level:
cargo run -- --log-level disease_model=trace
logging
The trace!, info!, and error! logging macros allow us to print messages
to the console, but they are much more powerful than a simple print statement.
With log messages, you can:
- Turn log messages on and off as needed.
- Enable only messages with a specified priority (for example, only warnings or higher).
- Filter messages to show only those emitted from a specific module, like the
peoplemodule we write in the next section.
See the logging documentation for more details.
command line arguments
The run_with_args() function takes care of handling any command line
arguments for us, which is why we don't just create a Context object and
call context.execute() ourselves. There are many arguments we can pass to
our model that affect what is output and where, debugging options,
configuration input, and so forth. See the command line documentation for more
details.
In the next section we will add people to our model.
The People Module
In Ixa we organize our models into modules each of which is responsible for a single aspect of the model.
modules
In fact, the code of Ixa itself is organized into modules in just the same way models are.
Ixa is a framework for developing agent-based models. In most of our models,
the agents will represent people. So let's create a module that is responsible
for people and their properties—the data that is attached to each person. Create
a new file in the src directory called people.rs.
Defining an Entity and Property
use ixa::prelude::*;
use ixa::trace;
use crate::POPULATION;
define_entity!(Person);
define_property!(
// The type of the property
enum InfectionStatus {
S,
I,
R,
},
// The entity the property is associated with
Person,
// The property's default value for newly created `Person` entities
default_const = InfectionStatus::S
);
/// Populates the "world" with the `POPULATION` number of people.
pub fn init(context: &mut Context) {
trace!("Initializing people");
for _ in 0..POPULATION {
let _: PersonId = context.add_entity(()).expect("failed to add person");
}
}
We have to define the Person entity before we can associate properties with
it. The define_entity!(Person) macro invocation automatically defines the
Person type, implements the Entity trait for Person, and creates the type
alias PersonId = EntityId, which is the type we can use to represent
specific instances of our entity, a single person, in our simulation.
To each person we will associate a value of the enum (short for “enumeration”)
named InfectionStatus. An enum is a way to create a type that can be one of
several predefined values. Here, we have three values:
- S: Represents someone who is susceptible to infection.
- I: Represents someone who is currently infected.
- R: Represents someone who has recovered.
Each value in the enum corresponds to a stage in our simple model. The enum
value for a person's InfectionStatus property will refer to an individual’s
health status in our simulation.
The module's init() function
While not strictly enforced by Ixa, the general formula for an Ixa module is:
- "public" data types and functions
- "private" data types and functions
The init() function is how your module will insert any data into the context
and set up whatever initial conditions it requires before the simulation begins.
For our people module, the init() function just inserts people into the
Context.
/// Populates the "world" with people.
pub fn init(context: &mut Context) {
trace!("Initializing people");
for _ in 0..1000 {
let _: PersonId = context.add_entity(()).expect("failed to add person");
}
}
The context.add_entity() method call might look a little odd, because we are
not giving context any data to insert, but that is because our one and only
Property was defined to have a default value of InfectionStatus::S
(susceptible)—so context.add_entity() doesn't need any information to create a
new person. Another odd thing is the .expect("failed to add person") method
call. In more complicated scenarios adding a person can fail. We could intercept
that failure if we wanted, but in this simple case we will just let the program
crash with a message about the reason: "failed to add person".
Finally, the Context::add_entity method returns an entity ID wrapped in a
Result, which the expect method unwraps. We can use this ID if we need to
refer to this newly created person. Since we don't need it, we assign the value
to the special "don't care" variable _ (underscore), which just throws the
value away. Why assign it to anything, though? So that the compiler can infer
that it is a Person we are creating, as opposed to some other entity we may
have defined. If we just omitted the let _: PersonId = part completely, we
would need to explicitly specify the entity type using
turbo fish notation.
Constants
Having "magic numbers" embedded in your code, such as the constant 1000 here
representing the total number of people in our model, is bad practice.
What if we want to change this value later? Will we even be able to find it in
all of our source code? Ixa has a formal mechanism for managing these kinds of
model parameters, but for now we will just define a "static constant" near the
top of src/main.rs named POPULATION and replace the literal 1000 with
POPULATION:
use ixa::prelude::*;
use ixa::trace;
use crate::POPULATION;
define_entity!(Person);
define_property!(
// The type of the property
enum InfectionStatus {
S,
I,
R,
},
// The entity the property is associated with
Person,
// The property's default value for newly created `Person` entities
default_const = InfectionStatus::S
);
/// Populates the "world" with the `POPULATION` number of people.
pub fn init(context: &mut Context) {
trace!("Initializing people");
for _ in 0..POPULATION {
let _: PersonId = context.add_entity(()).expect("failed to add person");
}
}
Let's revisit src/main.rs:
mod incidence_report;
mod infection_manager;
mod people;
mod transmission_manager;
use ixa::{Context, error, info, run_with_args};
static POPULATION: u64 = 100;
static FORCE_OF_INFECTION: f64 = 0.1;
static MAX_TIME: f64 = 200.0;
static INFECTION_DURATION: f64 = 10.0;
fn main() {
let result = run_with_args(|context: &mut Context, _args, _| {
// Add a plan to shut down the simulation after `max_time`, regardless of
// what else is happening in the model.
context.add_plan(MAX_TIME, |context| {
context.shutdown();
});
people::init(context);
transmission_manager::init(context);
infection_manager::init(context);
incidence_report::init(context).expect("Failed to init incidence report");
Ok(())
});
match result {
Ok(_) => {
info!("Simulation finished executing");
}
Err(e) => {
error!("Simulation exited with error: {}", e);
}
}
}
- Your IDE might have added the
mod people;line for you. If not, add it now. It tells the compiler that thepeoplemodule is attached to themainmodule (that is,main.rs). - We also need to declare our static constant for the total number of people.
- We need to initialize the people module.
Imports
Turning back to src/people.rs, your IDE might have been complaining to you
about not being able to find things "in this scope"—or, if you are lucky, your
IDE was smart enough to import the symbols you need at the top of the file
automatically. The issue is that the compiler needs to know where externally
defined items are coming from, so we need to have use statements at the top of
the file to import those items. Here is the complete src/people.rs file:
//people.rs
use ixa::prelude::*;
use ixa::trace;
use crate::POPULATION;
define_entity!(Person);
define_property!(
// The type of the property
enum InfectionStatus {
S,
I,
R,
},
// The entity the property is associated with
Person,
// The property's default value for newly created `Person` entities
default_const = InfectionStatus::S
);
/// Populates the "world" with the `POPULATION` number of people.
pub fn init(context: &mut Context) {
trace!("Initializing people");
for _ in 0..POPULATION {
let _: PersonId = context.add_entity(()).expect("failed to add person");
}
}
The Transmission Manager
We call the module in charge of initiating new infections the transmission
manager. Create the file src/transmission_manager.rs and add
mod transmission_manager; to the top of src/main.rs right next to the
mod people; statement. We need to flesh out this skeleton.
// transmission_manager.rs
use ixa::Context;
fn attempt_infection(context: &mut Context) {
}
pub fn init(context: &mut Context) {
trace!("Initializing transmission manager");
}
Constants
Recall our abstract model: We assume that each susceptible person has a constant
risk of becoming infected over time, independent of past infections, expressed
as a force of infection. Mathematically, this results in an exponentially
distributed duration between infection events. So we need to represent the
constant FORCE_OF_INFECTION and a random number source to sample exponentially
distributed random time durations.
We have already dealt with constants when we defined the constant POPULATION
in main.rs. Let's define FORCE_OF_INFECTION right next to it. We also cap
the simulation time to an arbitrarily large number, a good practice that
prevents the simulation from running forever in case we make a programming
error.
// main.rs
mod people;
mod transmission_manager;
use ixa::Context;
static POPULATION: u64 = 1000;
static FORCE_OF_INFECTION: f64 = 0.1;
static MAX_TIME: f64 = 200.0;
// ...the rest of the file...
Infection Attempts
We need to import these constants into transmission_manager. To define a new
random number source in Ixa, we use define_rng!. There are other symbols from
Ixa we will need for the implementation of attempt_infection(). You can have
your IDE add these imports for you as you go, or you can add them yourself now.
// transmission_manager.rs
use ixa::prelude::*;
use ixa::trace;
use rand_distr::Exp;
use crate::people::{InfectionStatus, PersonId};
use crate::{FORCE_OF_INFECTION, POPULATION};
define_rng!(TransmissionRng);
fn attempt_infection(context: &mut Context) {
trace!("Attempting infection");
let person_to_infect: PersonId = context.sample_entity(TransmissionRng, ()).unwrap();
let person_status: InfectionStatus = context.get_property(person_to_infect);
if person_status == InfectionStatus::S {
context.set_property(person_to_infect, InfectionStatus::I);
}
#[allow(clippy::cast_precision_loss)]
let next_attempt_time = context.get_current_time()
+ context.sample_distr(TransmissionRng, Exp::new(FORCE_OF_INFECTION).unwrap())
/ POPULATION as f64;
context.add_plan(next_attempt_time, attempt_infection);
}
pub fn init(context: &mut Context) {
trace!("Initializing transmission manager");
context.add_plan(0.0, attempt_infection);
}
// ...the rest of the file...
The function attempt_infection() needs to do the following:
- Randomly sample a person from the population to attempt to infect.
- Check the sampled person's current
InfectionStatus, changing it to infected (InfectionStatus::I) if and only if the person is currently susceptible (InfectionStatus::S). - Schedule the next infection attempt by inserting a plan into the timeline
that will run
attempt_infection()again.
use ixa::prelude::*;
use ixa::trace;
use rand_distr::Exp;
use crate::people::{InfectionStatus, PersonId};
use crate::{FORCE_OF_INFECTION, POPULATION};
define_rng!(TransmissionRng);
fn attempt_infection(context: &mut Context) {
trace!("Attempting infection");
let person_to_infect: PersonId = context.sample_entity(TransmissionRng, ()).unwrap();
let person_status: InfectionStatus = context.get_property(person_to_infect);
if person_status == InfectionStatus::S {
context.set_property(person_to_infect, InfectionStatus::I);
}
#[allow(clippy::cast_precision_loss)]
let next_attempt_time = context.get_current_time()
+ context.sample_distr(TransmissionRng, Exp::new(FORCE_OF_INFECTION).unwrap())
/ POPULATION as f64;
context.add_plan(next_attempt_time, attempt_infection);
}
pub fn init(context: &mut Context) {
trace!("Initializing transmission manager");
context.add_plan(0.0, attempt_infection);
}
Read through this implementation and make sure you understand how it accomplishes the three tasks above. A few observations:
- The method call
context.sample_entity(TransmissionRng, ())takes the name of a random number source and a query and returns anOption<PersonId>, which can have the value ofSome(PersonId)orNone. In this case, we give it the "empty query"(), which means we want to sample from the entire population. The population will never be empty, so the result will never beNone, and so we just callunwrap()on theSome(PersonId)value to get thePersonId. - The
#[allow(clippy::cast_precision_loss)]is optional; without it the compiler will warn you about convertingpopulation's integral typeusizeto the floating point typef64, but we know that this conversion is safe to do in this context. - If the sampled person is not susceptible, then the only thing this function does is schedule the next attempt at infection.
- The time at which the next attempt is scheduled is sampled randomly from the
exponential distribution according to our abstract model and using the random
number source
TransmissionRngthat we defined specifically for this purpose. - None of this code refers to the people module (except to import the types
InfectionStatusandPersonId) or the infection manager we are about to write, demonstrating the software engineering principle of modularity.
random number generators
Each module generally defines its own random number source with define_rng!,
avoiding interfering with the random number sources used elsewhere in the
simulation in order to preserve determinism. In Monte Carlo simulations,
deterministic pseudorandom number sequences are desirable because they
ensure reproducibility, improve efficiency, provide control over randomness,
enable consistent statistical testing, and reduce the likelihood of bias or
error. These qualities are critical in scientific computing, optimization
problems, and simulations that require precise and verifiable results.
The Infection Manager
The infection manager (infection_manager.rs) is responsible for the evolution
of an infected person after they have been infected. In this simple model, there
is only one thing for the infection manager to do: schedule the time an infected
person recovers. We've already seen how to change a person's InfectionStatus
property and how to schedule plans on the timeline in the transmission module.
But how does the infection manager know about new infections?
Events
Modules can subscribe to events. The infection manager registers a function with Ixa that will be called in response to a change in a particular property.
// in infection_manager.rs
use ixa::prelude::*;
use rand_distr::Exp;
use crate::INFECTION_DURATION;
use crate::people::{InfectionStatus, Person, PersonId};
pub type InfectionStatusEvent = PropertyChangeEvent<Person, InfectionStatus>;
define_rng!(InfectionRng);
fn schedule_recovery(context: &mut Context, person_id: PersonId) {
trace!("Scheduling recovery");
let current_time = context.get_current_time();
let sampled_infection_duration =
context.sample_distr(InfectionRng, Exp::new(1.0 / INFECTION_DURATION).unwrap());
let recovery_time = current_time + sampled_infection_duration;
context.add_plan(recovery_time, move |context| {
context.set_property(person_id, InfectionStatus::R);
});
}
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Handling infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
if event.current == InfectionStatus::I {
schedule_recovery(context, event.entity_id);
}
}
pub fn init(context: &mut Context) {
trace!("Initializing infection_manager");
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
}
This line isn't defining a new struct or even a new type. Rather, it defines an
alias for PropertyChangeEvent<E: Entity, P: Property<E>> with the generic
types instantiated for the property we want to monitor, InfectionStatus. This
is effectively the name of the event we subscribe to in the module's init()
function:
// in infection_manager.rs
use ixa::prelude::*;
use rand_distr::Exp;
use crate::INFECTION_DURATION;
use crate::people::{InfectionStatus, Person, PersonId};
pub type InfectionStatusEvent = PropertyChangeEvent<Person, InfectionStatus>;
define_rng!(InfectionRng);
fn schedule_recovery(context: &mut Context, person_id: PersonId) {
trace!("Scheduling recovery");
let current_time = context.get_current_time();
let sampled_infection_duration =
context.sample_distr(InfectionRng, Exp::new(1.0 / INFECTION_DURATION).unwrap());
let recovery_time = current_time + sampled_infection_duration;
context.add_plan(recovery_time, move |context| {
context.set_property(person_id, InfectionStatus::R);
});
}
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Handling infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
if event.current == InfectionStatus::I {
schedule_recovery(context, event.entity_id);
}
}
pub fn init(context: &mut Context) {
trace!("Initializing infection_manager");
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
}
The event handler is just a regular Rust function that takes a Context and an
InfectionStatusEvent, the latter of which holds the PersonId of the person
whose InfectionStatus changed, the current InfectionStatus value, and the
previous InfectionStatus value.
// in infection_manager.rs
use ixa::prelude::*;
use rand_distr::Exp;
use crate::INFECTION_DURATION;
use crate::people::{InfectionStatus, Person, PersonId};
pub type InfectionStatusEvent = PropertyChangeEvent<Person, InfectionStatus>;
define_rng!(InfectionRng);
fn schedule_recovery(context: &mut Context, person_id: PersonId) {
trace!("Scheduling recovery");
let current_time = context.get_current_time();
let sampled_infection_duration =
context.sample_distr(InfectionRng, Exp::new(1.0 / INFECTION_DURATION).unwrap());
let recovery_time = current_time + sampled_infection_duration;
context.add_plan(recovery_time, move |context| {
context.set_property(person_id, InfectionStatus::R);
});
}
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Handling infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
if event.current == InfectionStatus::I {
schedule_recovery(context, event.entity_id);
}
}
pub fn init(context: &mut Context) {
trace!("Initializing infection_manager");
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
}
We only care about new infections in this model.
Scheduling Recovery
As in attempt_infection(), we sample the recovery time from the exponential
distribution with mean INFECTION_DURATION, a constant we define in main.rs.
We define a random number source for this module's exclusive use with
define_rng!(InfectionRng) as we did before.
use ixa::prelude::*;
use rand_distr::Exp;
use crate::INFECTION_DURATION;
use crate::people::{InfectionStatus, Person, PersonId};
pub type InfectionStatusEvent = PropertyChangeEvent<Person, InfectionStatus>;
define_rng!(InfectionRng);
fn schedule_recovery(context: &mut Context, person_id: PersonId) {
trace!("Scheduling recovery");
let current_time = context.get_current_time();
let sampled_infection_duration =
context.sample_distr(InfectionRng, Exp::new(1.0 / INFECTION_DURATION).unwrap());
let recovery_time = current_time + sampled_infection_duration;
context.add_plan(recovery_time, move |context| {
context.set_property(person_id, InfectionStatus::R);
});
}
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Handling infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
if event.current == InfectionStatus::I {
schedule_recovery(context, event.entity_id);
}
}
pub fn init(context: &mut Context) {
trace!("Initializing infection_manager");
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
}
Notice that the plan is again just a Rust function, but this time it takes the form of a closure rather than a traditionally defined function. This is convenient when the function is only a line or two.
closures and captured variables
The move keyword in the syntax for Rust closures instructs the closure to
take ownership of any variables it uses from its surrounding context—these are
known as captured variables. Normally, when a closure refers to variables
defined outside of its own body, it borrows them, which means it uses
references to those values. However, with move, the closure takes full
ownership by moving the variables into its own scope. This is especially
useful when the closure must outlive the current scope or be passed to another
thread, as it ensures that the closure has its own independent copy of the
data without relying on references that might become invalid.
The Incident Reporter
An agent-based model does not output an answer at the end of a simulation in the usual sense. Rather, the simulation evolves the state of the world over time. If we want to track that evolution for later analysis, it is up to us to collect the data we want to have. The built-in report feature makes it easy to record data to a CSV file during the simulation.
Our model will only have a single report that records the current in-simulation
time, the PersonId, and the InfectionStatus of a person whenever their
InfectionStatus changes. We define a struct representing a single row of data.
// in incidence_report.rs
use std::path::PathBuf;
use ixa::prelude::*;
use ixa::trace;
use serde::Serialize;
use crate::infection_manager::InfectionStatusEvent;
use crate::people::{InfectionStatus, PersonId};
#[derive(Serialize, Clone)]
struct IncidenceReportItem {
time: f64,
person_id: PersonId,
infection_status: InfectionStatus,
}
define_report!(IncidenceReportItem);
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Recording infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
context.send_report(IncidenceReportItem {
time: context.get_current_time(),
person_id: event.entity_id,
infection_status: event.current,
});
}
pub fn init(context: &mut Context) -> Result<(), IxaError> {
trace!("Initializing incidence_report");
// Output directory is relative to the directory with the Cargo.toml file.
let output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// In the configuration of report options below, we set `overwrite(true)`, which is not
// recommended for production code in order to prevent accidental data loss. It is set
// here so that newcomers won't have to deal with a confusing error while running
// examples.
context
.report_options()
.directory(output_path)
.overwrite(true);
context.add_report::<IncidenceReportItem>("incidence")?;
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
Ok(())
}
The fact that IncidenceReportItem derives Serialize is what makes this magic
work. We define a report for this struct using the define_report! macro.
use std::path::PathBuf;
use ixa::prelude::*;
use ixa::trace;
use serde::Serialize;
use crate::infection_manager::InfectionStatusEvent;
use crate::people::{InfectionStatus, PersonId};
#[derive(Serialize, Clone)]
struct IncidenceReportItem {
time: f64,
person_id: PersonId,
infection_status: InfectionStatus,
}
define_report!(IncidenceReportItem);
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Recording infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
context.send_report(IncidenceReportItem {
time: context.get_current_time(),
person_id: event.entity_id,
infection_status: event.current,
});
}
pub fn init(context: &mut Context) -> Result<(), IxaError> {
trace!("Initializing incidence_report");
// Output directory is relative to the directory with the Cargo.toml file.
let output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// In the configuration of report options below, we set `overwrite(true)`, which is not
// recommended for production code in order to prevent accidental data loss. It is set
// here so that newcomers won't have to deal with a confusing error while running
// examples.
context
.report_options()
.directory(output_path)
.overwrite(true);
context.add_report::<IncidenceReportItem>("incidence")?;
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
Ok(())
}
The way we listen to events is almost identical to how we did it in the
infection module. First let's make the event handler, that is, the callback
that will be called whenever an event is emitted.
use std::path::PathBuf;
use ixa::prelude::*;
use ixa::trace;
use serde::Serialize;
use crate::infection_manager::InfectionStatusEvent;
use crate::people::{InfectionStatus, PersonId};
#[derive(Serialize, Clone)]
struct IncidenceReportItem {
time: f64,
person_id: PersonId,
infection_status: InfectionStatus,
}
define_report!(IncidenceReportItem);
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Recording infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
context.send_report(IncidenceReportItem {
time: context.get_current_time(),
person_id: event.entity_id,
infection_status: event.current,
});
}
pub fn init(context: &mut Context) -> Result<(), IxaError> {
trace!("Initializing incidence_report");
// Output directory is relative to the directory with the Cargo.toml file.
let output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// In the configuration of report options below, we set `overwrite(true)`, which is not
// recommended for production code in order to prevent accidental data loss. It is set
// here so that newcomers won't have to deal with a confusing error while running
// examples.
context
.report_options()
.directory(output_path)
.overwrite(true);
context.add_report::<IncidenceReportItem>("incidence")?;
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
Ok(())
}
Just pass a IncidenceReportItem to context.send_report()! We also emit a
trace log message so we can trace the execution of our model.
In the init() function there is a little bit of setup needed. Also, we can't
forget to register this callback to listen to InfectionStatusEvents.
use std::path::PathBuf;
use ixa::prelude::*;
use ixa::trace;
use serde::Serialize;
use crate::infection_manager::InfectionStatusEvent;
use crate::people::{InfectionStatus, PersonId};
#[derive(Serialize, Clone)]
struct IncidenceReportItem {
time: f64,
person_id: PersonId,
infection_status: InfectionStatus,
}
define_report!(IncidenceReportItem);
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Recording infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
context.send_report(IncidenceReportItem {
time: context.get_current_time(),
person_id: event.entity_id,
infection_status: event.current,
});
}
pub fn init(context: &mut Context) -> Result<(), IxaError> {
trace!("Initializing incidence_report");
// Output directory is relative to the directory with the Cargo.toml file.
let output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// In the configuration of report options below, we set `overwrite(true)`, which is not
// recommended for production code in order to prevent accidental data loss. It is set
// here so that newcomers won't have to deal with a confusing error while running
// examples.
context
.report_options()
.directory(output_path)
.overwrite(true);
context.add_report::<IncidenceReportItem>("incidence")?;
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
Ok(())
}
Note that:
- the configuration you do on
context.report_options()applies to all reports attached to that context; - using
overwrite(true)is useful for debugging but potentially devastating for production; - this
init()function returns a result, which will be whatever error thatcontext.add_report()returns if the CSV file cannot be created for some reason, orOk(())otherwise.
result and handling errors
The Rust Result<U, V> type is an enum used for error handling. It
represents a value that can either be a successful outcome (Ok) containing a
value of type U, or an error (Err) containing a value of type V. Think
of it as a built-in way to return and propagate errors without relying on
exceptions, similar to using “Either” types or special error codes in other
languages.
The ? operator works with Result to simplify error handling. When you
append ? to a function call that returns a Result, it automatically checks
if the result is an Ok or an Err. If it’s Ok, the value is extracted; if
it’s an Err, the error is immediately returned from the enclosing function.
This helps keep your code concise and easy to read by reducing the need for
explicit error-checking logic.
If your IDE isn't capable of adding imports for you, the external symbols we need for this module are as follows.
use std::path::PathBuf;
use ixa::prelude::*;
use ixa::trace;
use serde::Serialize;
use crate::infection_manager::InfectionStatusEvent;
use crate::people::{InfectionStatus, PersonId};
#[derive(Serialize, Clone)]
struct IncidenceReportItem {
time: f64,
person_id: PersonId,
infection_status: InfectionStatus,
}
define_report!(IncidenceReportItem);
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
trace!(
"Recording infection status change from {:?} to {:?} for {:?}",
event.previous, event.current, event.entity_id
);
context.send_report(IncidenceReportItem {
time: context.get_current_time(),
person_id: event.entity_id,
infection_status: event.current,
});
}
pub fn init(context: &mut Context) -> Result<(), IxaError> {
trace!("Initializing incidence_report");
// Output directory is relative to the directory with the Cargo.toml file.
let output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// In the configuration of report options below, we set `overwrite(true)`, which is not
// recommended for production code in order to prevent accidental data loss. It is set
// here so that newcomers won't have to deal with a confusing error while running
// examples.
context
.report_options()
.directory(output_path)
.overwrite(true);
context.add_report::<IncidenceReportItem>("incidence")?;
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
Ok(())
}
Next Steps
We have created several new modules. We need to make sure they are each
initialized with the Context before the simulation starts. Below is main.rs
in its entirety.
// main.rs
mod incidence_report;
mod infection_manager;
mod people;
mod transmission_manager;
use ixa::{Context, error, info, run_with_args};
static POPULATION: u64 = 100;
static FORCE_OF_INFECTION: f64 = 0.1;
static MAX_TIME: f64 = 200.0;
static INFECTION_DURATION: f64 = 10.0;
fn main() {
let result = run_with_args(|context: &mut Context, _args, _| {
// Add a plan to shut down the simulation after `max_time`, regardless of
// what else is happening in the model.
context.add_plan(MAX_TIME, |context| {
context.shutdown();
});
people::init(context);
transmission_manager::init(context);
infection_manager::init(context);
incidence_report::init(context).expect("Failed to init incidence report");
Ok(())
});
match result {
Ok(_) => {
info!("Simulation finished executing");
}
Err(e) => {
error!("Simulation exited with error: {}", e);
}
}
}
Exercises:
- Currently the simulation runs until
MAX_TIMEeven if every single person has been infected and has recovered. Add a check somewhere that callscontext.shutdown()if there is no more work for the simulation to do. Where should this check live? - Analyze the data output by the incident reporter. Plot the number of people
with each
InfectionStatuson the same axis to see how they change over the course of the simulation. Are the curves what we expect to see given our abstract model? - Add another property that moderates the risk of infection of the individual. (Imagine, for example, people wearing face masks for an airborne illness.) Give a randomly sampled subpopulation that intervention and add a check to the transmission module to see if the person that we are attempting to infect has that property. Change the probability of infection accordingly.
Topics
Understanding Indexing in Ixa
Syntax and Best Practices
Syntax:
// For single property indexes
// Somewhere during the initialization of `context`:
context.index_property::<Person, Age>();
// For multi-indexes
// Where properties are defined:
define_multi_property!((Name, Age, Weight), Person);
// Somewhere during the initialization of `context`:
context.index_property::<Person, (Name, Age, Weight)>();
Best practices:
- Index a property to improve performance of queries of that property.
- Create a multi-property index to improve performance of queries involving multiple properties.
- The cost of creating indexes is increased memory use, which can be significant for large populations. So it is best to only create indexes / multi-indexes that actually improve model performance.
- It may be best to call
context.index_property::<Entity, Propertyin the>() init()method of the module in which the property is defined, or you can put all of yourContext::index_propertycalls together in a main initialization function if you prefer. - It is not an error to call
Context::index_propertyin the middle of a running simulation or to call it twice for the same property, but it makes little sense to do so.
Property Value Storage in Ixa
To understand why some operations in Ixa are slow without an index, we need to understand how property data is stored internally and how an index provides Ixa an alternative view into that data.
In Ixa, each agent in a simulation—such as a person in a disease transmission model—is associated with a unique row of data. This data is stored in columnar form, meaning each property or field of a person (such as infection status, age, or household) is stored as its own column. This structure allows for fast and memory-efficient processing.
Let’s consider a simple example with two fields: PersonId and
InfectionStatus.
PersonId: a unique identifier for each individual, which is represented as an integer internally (e.g., 1001, 1002, 1003, …).InfectionStatus: a status value indicating whether the individual issusceptible,infected, orrecovered.
At a particular time during our simulation, we might have the following data:
PersonId | InfectionStatus |
|---|---|
| 0 | susceptible |
| 1 | infected |
| 2 | susceptible |
| 3 | recovered |
| 4 | susceptible |
| 5 | susceptible |
| 6 | infected |
| 7 | susceptible |
| 8 | infected |
| 9 | susceptible |
| 10 | recovered |
| 11 | infected |
| 12 | infected |
| 13 | infected |
| 14 | recovered |
In the default representation used by Ixa, each field is stored as a column.
Internally, however, PersonId is not stored explicitly as data. Instead, it
is implicitly defined by the row number in the columnar data structure. That
is:
- The row number acts as the unique index (
PersonId) for each individual. - The
InfectionStatusvalues are stored in a single contiguous array, where the entry at positionigives the status for the person withPersonIdequal toi.
In this default layout, accessing the infection status for a person is a simple array lookup, which is extremely fast and requires minimal memory overhead.
But suppose instead of looking up the infection status of a particular
PersonId, you wanted to look up which PersonId's were associated to a
particular infection status, say, infected. If the the property is not
indexed, Ixa has to scan through the entire column and collect all PersonId's
(row numbers) for which InfectionStatus has the value infected, and it has
to do this each and every time we run a query for that property. If we do this
frequently, all of this scanning can add up to quite a long time!
Property Index Structure
We could save a lot of time if we scanned through the InfectionStatus column
once, collected the PersonId 's for each InfectionStatus value, and just
reused this table each time we needed to do this lookup. That's all an index is!
The index for our example column of data:
InfectionStatus | List of PersonId 's |
|---|---|
susceptible | [0, 2, 4, 5, 7, 9] |
infected | [1, 6, 8, 11, 12, 13] |
recovered | [3, 10, 14] |
An index in Ixa is just a map between a property value and the list of all
PersonId's having that value. Now looking up the PersonId's for a given
property value is (almost) as fast as looking up the property value for a given
PersonId.
ixa's intelligent indexing strategy
The picture we have painted of how Ixa implements indexing is necessarily a simplification. Ixa uses a lazy indexing strategy, which means if a property is never queried, then Ixa never actually does the work of computing the index. Ixa also keeps track of whether new people have been added to the simulation since the last time a query was run, so that it only has to update its index for those newly added people. These and other optimizations make Ixa's indexing very fast and memory efficient compared to the simplistic version described in this section.
The Costs of Creating an Index
There are two costs you have to pay for indexing:
- The index needs to be maintained as the simulation evolves the state of the population. Every change to any person's infection status needs to be reflected in the index. While this operation is fast for a single update, it isn't instant, and the accumulation of millions of little updates to the index can add up to a real runtime cost.
- The index uses memory. In fact, it uses more memory than the original column
of data, because it has to store both the
InfectionStatusvalues (in our example) and thePersonIdvalues, while the original column only stores theInfectionStatus(thePersonId's were implicitly the row numbers).
creating vs. maintaining an index
Suspiciously missing from this list of costs is the initial cost of scanning through the property column to create the index in the first place, but actually whether you maintain the index from the very beginning or you index it all at once doesn't matter: the sum of all the small efforts to update the index every time a person is added is equal to the cost of creating the index from scratch for an existing set of data.
We can, however, save the effort of updating the index when property values change if we wait until we actually run a query that needs to use the index before we construct the index. This is why Ixa uses a lazy indexing strategy.
Usually scanning through the whole property column is so slow relative to maintaining an index that the extra computational cost of maintaining the index is completely dwarfed by the time savings, even for infrequently queried properties. In other words, in terms of running time, an index is almost always worth it. For smaller population sizes in particular, at worst you shouldn't see a meaningful slow-down.
Memory use is a different story. In a model with tens of millions of people and many properties, you might want to be more thoughtful about which properties you index, as memory use can reach into the gigabytes. While we are in an era where tens of gigabytes of RAM is commonplace in workstations, cloud computing costs and the selection of appropriate virtual machine sizes for experiments in production recommend that we have a feel for whether we really need the resources we are using.
a query might be the wrong tool for the job
Sometimes, the best way to address a slow query in your model isn’t to add indexes, but to remove the query entirely. A common scenario is when you want to report on some aggregate statistics, for example, the total number of people having each infectiousness status. It might be much better to just track the aggregate value directly than to run a query for it every time you want to write it to a report. As usual, when it comes to performance issues, measure your specific use case to know for sure what the best strategy is.
Multi Property Indexes
To speed up queries involving multiple properties, use a multi-property index
(or multi-index for short), which indexes multiple properties jointly.
Suppose we have the properties AgeGroup and InfectionStatus, and we want to
speed up queries of these two properties:
let age_and_status = context.query_result_iterator((AgeGroup(30), InfectionStatus::Susceptible)); // Bottleneck
We could index AgeGroup and InfectionStatus individually, but in this case
we can do even better with a multi-index, which treats the pairs of values
(AgeGroup, InfectionStatus) as if it were a single value. Such a multi-index
might look like this:
(AgeGroup, InfectionStatus) | PersonId's |
|---|---|
(10, susceptible) | [16, 27, 31] |
(10, infected) | [38] |
(10, recovered) | [18, 23, 29, 34, 39] |
(20, susceptible) | [12, 25, 26] |
(20, infected) | [2, 3, 9, 14, 17, 19, 28, 33] |
(20, recovered) | [13, 20, 22, 30, 37] |
(30, susceptible) | [0, 1, 11, 21] |
(30, infected) | [5, 6, 7, 10, 15, 24, 32] |
(30, recovered) | [4, 8, 35, 36] |
Ixa hides the boilerplate required for creating a multi-index with the macro
define_multi_property!:
define_multi_property!((AgeGroup, InfectionStatus), Person);
Creating a multi-index does not automatically create indexes for each of the properties individually, but you can do so yourself if you wish, for example, if you had other single property queries you want to speed up.
The Benefits of Indexing - A Case Study
In the Ixa source repository you will find the births-deaths example in the
examples/ directory. You can build and run this example with the following
command:
cargo run --example births-death
Now let's edit the input.json file and change the population size to 1000:
{
"population": 1000,
"max_time": 780.0,
"seed": 123,
⋮
}
We can time how long the simulation takes to run with the time command. Here's
what the command and output look like on my machine:
$ time cargo run --example births-deaths
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/examples/births-deaths`
cargo run --example births-deaths 362.55s user 1.69s system 99% cpu 6:06.35 total
For a population size of only 1000 it takes more than six minutes to run!
Let's index the InfectionStatus property. In
examples/births-deaths/src/lib.rs we add the following line somewhere in the
initialize() function:
context.index_property::<Person, InfectionStatus>();
We also need to import InfectionStatus by putting
use crate::population_manager::InfectionStatus; near the top of the file. To
be fair, let's compile the example separately so we don't include the compile
time in the run time:
cargo build --example births-deaths
Now run it again:
$ time cargo run --example births-deaths
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/examples/births-deaths`
cargo run --example births-deaths 5.79s user 0.07s system 97% cpu 5.990 total
From six minutes to six seconds! This kind of dramatic speedup is typical with indexes. It allows models that would otherwise struggle with a population size of 1000 to handle populations in the tens of millions.
Exercises:
- Even six seconds is an eternity for modern computer processors. Try to get this example to run with a population of 1000 in ~1 second*, two orders of magnitude faster than the unindexed version, by indexing other additional properties.
- Using only a single property index of
InfectionStatusand a single multi-index, get this example to run in ~0.5 seconds. This illustrates that it's better to index the right properties than to just index everything.
*Your timings will be different but should be roughly proportional to these.
Performance and Profiling
Indexing
Indexing properties that are queried repeatedly in your simulation can lead to dramatic speedups. It is not uncommon to see two or more orders of magnitude of improvement in some cases. It is also very simple to do.
You can index a single property, or you can index multiple properties jointly.
Just include the following method call(s) during the initialization of
context, replacing the example property names with your own:
// For single property indexes
// Somewhere during the initialization of `context`:
context.index_property::<Person, Age>();
// For multi-indexes
// Where properties are defined:
define_multi_property!((Name, Age, Weight), Person);
// Somewhere during the initialization of `context`:
context.index_property::<Person, (Name, Age, Weight)>();
The cost of creating indexes is increased memory use, which can be significant for large populations. So it is best to only create indexes / multi-indexes that actually improve model performance, especially if cloud computing costs / VM sizes are an issue.
See the chapter on Indexing for full details.
Optimizing Performance with Build Profiles
Build profiles allow you to configure compiler settings for different kinds of
builds. By default, Cargo uses the dev profile, which is usually what you want
for normal development of your model but which does not perform optimization.
When you are ready to run a real experiment with your project, you will want to
use the release build profile, which does more aggressive code optimization
and disables runtime checks for numeric overflow and debug assertions. In some
cases, this can improve performance dramatically.
The
Cargo documentation for build profiles
describes many different settings you can tweak. You are not limited to Cargo's
built in profiles either. In fact, you might wish to create your own profile for
creating flame graphs, for example, as we do in the section on flame graphs
below. These settings go under [profile.release] or a custom profile like
[profile.bench] in your Cargo.toml file. For maximum execution speed,
the key trio is:
[profile.release]
opt-level = 3 # Controls the level of optimization. 3 = highest runtime speed. "s"/"z" = size-optimized.
lto = true # Link Time Optimization. Improves runtime performance by optimizing across crate boundaries.
codegen-units = 1 # Number of codegen units. Lower = better optimization. 1 enables whole-program optimization.
The Cargo documentation for build profiles describes a few more settings that can affect runtime performance, but these are the most important.
Ixa Profiling Module
For Ixa's built-in profiling (named counts, spans, and JSON output), see the Profiling Module topic.
Visualizing Execution with Flame Graphs
Samply and Flame Graph are easy to use profiling tools that generate a "flame graph" that visualizes stack traces, which allow you to see how much execution time is spent in different parts of your program. We demonstrate how to use Samply, which has better macOS support.
Install the samply tool with Cargo:
cargo install samply
For best results, build your project in both release mode and with debug
info. The easiest way to do this is to make a build profile, which we name
"profiling" below, by adding the following section to your Cargo.toml file:
[profile.profiling]
inherits = "release"
debug = true
Now when we build the project we can specify this build profile to Cargo by name:
cargo build --profile profiling
This creates your binary in target/profiling/my_project, where my_project is
standing in for the name of the project. Now run the project with samply:
samply record ./target/profiling/my_project
We can pass command line arguments as usual if we need to:
samply record ./target/profiling/my_project arg1 arg2
When execution completes, samply will open the results in a browser. The graph looks something like this:
The graph shows the "stack trace," that is, nested function calls, with a
"deeper" function call stacked on top of the function that called it, but does
not otherwise preserve chronological order of execution. Rather, the width of
the function is proportional the time spent within the function over the course
of the entire program execution. Since everything is ultimately called from your
main function, you can see main at the bottom of the pile stretching the
full width of the graph. This way of representing program execution allows you
to identify "hot spots" where your program is spending most of its time.
Using Logging to Profile Execution
For simple profiling during development, it is easy to use logging to measure how long certain operations take. This is especially useful when you want to understand the cost of specific parts of your application — like loading a large file.
cultivate good logging habits
It's good to cultivate the habit of adding trace! and debug! logging
messages to your code. You can always selectively enable or disable messages
for different parts of your program with per-module log level filters. (See
the logging module documentation for
details.)
Suppose we want to know how long it takes to load data for a large population before we start executing our simulation. We can do this with the following pattern:
use std::fs::File;
use std::io::BufReader;
use std::time::Instant;
use ixa::trace;
fn load_population_data(path: &str, context: &mut Context) {
// Record the start time before we begin loading the data.
let start = Instant::now();
let file = File::open(path)?;
let mut reader = BufReader::new(file);
// .. code to load in the data goes here ...
// This line computes the time that has elapsed since `start`.
let duration = start.elapsed();
trace!("Loaded population data from {} in {:?}", path, duration);
}
This pattern is especially useful to pair with a progress bar as in the next section.
Progress Bar
Provides functions to set up and update a progress bar.
A progress bar has a label, a maximum progress value, and its current progress,
which starts at zero. The maximum and current progress values are constrained to
be of type usize. However, convenience methods are provided for the common
case of a progress bar for the timeline that take f64 time values and rounds
them to nearest integers for you.
Only one progress bar can be active at a time. If you try to set a second
progress bar, the new progress bar will replace this first. This is useful if
you want to track the progress of a simulation in multiple phases. Keep in mind,
however, that if you define a timeline progress bar, the Context will try to
update it in its event loop with the current time, which might not be what you
want if you have replaced the progress bar with a new one.
Timeline Progress Bar
/// Initialize the progress bar with the maximum time until the simulation ends.
pub fn init_timeline_progress_bar(max_time: f64);
/// Updates the progress bar with the current time. Finalizes the progress bar when
/// `current_time >= max_time`.
pub fn update_timeline_progress(mut current_time: f64);
Custom Progress Bar
If the timeline is not a good indication of progress for your simulation, you can set up a custom progress bar.
/// Initializes a custom progress bar with the given label and max value.
pub fn init_custom_progress_bar(label: &str, max_value: usize);
/// Updates the current value of the custom progress bar.
pub fn update_custom_progress(current_value: usize);
/// Increments the custom progress bar by 1. Use this if you don't want to keep track of the
/// current value.
pub fn increment_custom_progress();
Custom Example: People Infected
Suppose you want a progress bar that tracks how much of the population has been infected (or infected and then recovered). You first initialize a custom progress bar before executing the simulation.
use crate::progress_bar::{init_custom_progress_bar};
init_custom_progress_bar("People Infected", POPULATION_SIZE);
To update the progress bar, we need to listen to the infection status property change event.
use crate::progress_bar::{increment_custom_progress};
// You might already have this event defined for other purposes.
pub type InfectionStatusEvent = PropertyChangeEvent<Person, InfectionStatus>;
// This will handle the status change event, updating the progress bar
// if there is a new infection.
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
// We only increment the progress bar when a new infection occurs.
if (InfectionStatusValue::Susceptible, InfectionStatusValue::Infected)
== (event.previous, event.current)
{
increment_custom_progress();
}
}
// Be sure to subscribe to the event when you initialize the context.
pub fn init(context: &mut Context) -> Result<(), IxaError> {
// ... other initialization code ...
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
// ...
Ok(())
}
Additional Resources
For an in-depth look at performance in Rust programming, including many advanced tools and techniques, check out The Rust Performance Book.
Profiling Module
Ixa includes a lightweight, feature-gated profiling module you can use to:
- Count named events (and compute event rates)
- Time named operations ("spans")
- Print results to the console
- Write results to a JSON file along with execution statistics
The API lives under ixa::profiling and is behind the profiling Cargo feature
(enabled by default). If you disable the feature, the API becomes a no-op so you
can leave profiling calls in your code.
Example console output
Span Label Count Duration % runtime
----------------------------------------------------------------------
load_synth_population 1 950us 792ns 0.36%
infection_attempt 1035 6ms 33us 91ns 2.28%
sample_setting 1035 3ms 66us 52ns 1.16%
get_contact 1035 1ms 135us 202ns 0.43%
schedule_next_forecasted_infection 1286 22ms 329us 102ns 8.44%
Total Measured 1385 23ms 897us 146ns 9.03%
Event Label Count Rate (per sec)
-----------------------------------------------------
property progression 36 136.05
recovery 27 102.04
accepted infection attempt 1,035 3,911.50
forecasted infection 1,286 4,860.09
Infection Forecasting Efficiency: 80.48%
Basic usage
Count an event:
use ixa::profiling::increment_named_count;
increment_named_count("forecasted infection");
increment_named_count("accepted infection attempt");
Time an operation:
use ixa::profiling::{close_span, open_span};
let span = open_span("forecast loop");
// operation code here (algorithm, function call, etc.)
close_span(span); // optional; dropping the span also closes it
Spans also auto-close at end of scope (RAII), which is useful for early returns:
use ixa::profiling::open_span;
fn complicated_function() {
let _span = open_span("complicated function");
// Complicated control flow here, maybe with lots of `return` points.
} // `_span` goes out of scope, automatically closed.
Printing results to the console (after the simulation completes):
use ixa::profiling::print_profiling_data;
print_profiling_data();
This prints spans, counts, and any computed statistics. You can also call
print_named_spans(), print_named_counts(), and print_computed_statistics()
individually.
Minimal example
use ixa::prelude::*;
use ixa::profiling::*;
fn main() {
let mut context = Context::new();
context.add_plan(0.0, |context| {
increment_named_count("my_model:event");
{
let _span = open_span("my_model:expensive_step");
// ... do work ...
} // span auto-closes on drop
context.shutdown();
});
context.execute();
// Console output (spans, counts, computed statistics).
print_profiling_data();
// Writes JSON to: <output_dir>/<file_prefix>profiling.json
// using the same report options configuration as CSV reports.
context.write_profiling_data();
}
See examples/profiling in the repository for a more complete example,
including configuring report_options() to control the output directory, file
prefix, and overwrite behavior.
Writing JSON output
ProfilingContextExt::write_profiling_data() writes a pretty JSON file to:
<output_dir>/<file_prefix>profiling.json
using the same report_options() configuration as CSV reports (directory, file
prefix, overwrite). The JSON includes:
date_timeexecution_statisticsnamed_countsnamed_spanscomputed_statistics
Example:
use std::path::PathBuf;
use ixa::prelude::*;
use ixa::profiling::ProfilingContextExt;
fn main() {
let mut context = Context::new();
context
.report_options()
.directory(PathBuf::from("./output"))
.file_prefix("run_")
.overwrite(true);
// ... run the simulation ...
context.execute();
context.write_profiling_data();
}
Special names and coverage
Spans may overlap or nest. The sum of all individual span durations will not
generally equal total runtime. A special span named "Total Measured" is open
if and only if any other span is open; it tracks how much runtime is covered by
some span.
Computed statistics
You can register custom, derived metrics over collected ProfilingData using
add_computed_statistic(label, description, computer, printer). The "computer"
returns an Option (for conditionally defined statistics), and the "printer"
prints the computed value.
Computed statistics are printed by print_computed_statistics() and included in
the JSON under computed_statistics (label, description, value).
The supported computed value types are usize, i64, and f64.
API (simplified):
pub type CustomStatisticComputer<T> = Box<dyn (Fn(&ProfilingData) -> Option<T>) + Send + Sync>;
pub type CustomStatisticPrinter<T> = Box<dyn Fn(T) + Send + Sync>;
pub fn add_computed_statistic<T: ComputableType>(
label: &'static str,
description: &'static str,
computer: CustomStatisticComputer<T>,
printer: CustomStatisticPrinter<T>,
);
Example:
use ixa::profiling::{add_computed_statistic, increment_named_count};
increment_named_count("my_model:event");
increment_named_count("my_model:event");
add_computed_statistic::<usize>(
"my_model:event_count",
"Total example events",
Box::new(|data| data.counts.get("my_model:event").copied()),
Box::new(|value| println!("Computed my_model:event_count = {value}")),
);
Random Module
Challenges of randomness
Random sampling is a fundamental capability for most simulations. The requirement that experiments also need to be deterministic makes randomness a subtle issue.
randomness vs. determinism
A truly random number source produces numbers in a sequence that cannot be predicted even in principle. A pseudorandom number source produces numbers in a sequence according to a completely deterministic algorithm, so if you know the algorithm, you can predict the next number in the sequence exactly. However, good pseudorandom number generators can produce sequences that appear indistinguishable from a truly random sequence according to a battery of rigorous statistical tests. We sometimes use the word random when we actually mean pseudorandom. Because we want simulations to be reproducible, we want them to be deterministic. But we also want statistical randomness within the simulation. Pseudorandom number generators give us both determinism and statistical randomness.
Here are the primary ways we handle this challenge in Ixa:
- We use pseudorandom number generators (PRNG or just RNG for short), which when provided with a seed number will produce numbers in a statistically random sequence—except that using the same seed will produce the same random sequence. To run a "new" simulation, provide a different seed.
- The default hash function used in the Rust Standard Library for containers
like
std::collections::HashMapandstd::collections::HashSetis nondeterministic for technical reasons that don't apply to our application. Therefore, we provideixa::HashMapandixa::HashSet, which use a deterministic—and much faster—hash function. - Each module defines its own random number sources, so the use of RNGs in one module is somewhat isolated from RNGs used in other modules. Of course, if a module interacts with other parts of Ixa, its own random behavior can "leak" and affect the determinism of other modules as well.

Using distinct RNGs in distinct components helps maintain simulation
reproducibility and prevents unintended interactions between independent parts
of your model. When modules share RNG streams, adding a new random call in one
component can shift the entire sequence of random numbers consumed by other
components, making it impossible to isolate changes or compare scenarios
reliably. For example, if you're comparing two disease transmission scenarios
where only the infection rate differs, you want the same sequence of people to
be randomly selected for potential infection in both runs - but if these
scenarios share an RNG with a recovery module that makes different numbers of
random calls due to varying infection counts, the transmission module will
consume different parts of the random sequence, compromising the comparison. By
giving each module its own RNG, you ensure that changes in one module's random
behavior don't cascade through the entire simulation, enabling precise scenario
comparisons and reproducible debugging.
requirements for determinism
The determinism guarantee applies to repeated execution of the same model compiled with the same version of Ixa. However, you should not expect identical results between different versions of Ixa. When you make changes to your own code, it is very easy to change the simulation behavior even if you don't intend to. Do not be surprised if you get different results if you make changes to your own code.
How to make and use a random number source
1. Define an RNG with define_rng!
Suppose we want to make a transmission manager that randomly samples a person to
become infected. In transmission_manger.rs we define a random number source
for our exclusive use in the transmission manager using the define_rng! macro,
giving it a unique name:
define_rng!(TransmissionRng);
This macro generates all the code needed to define TransmissionRng.
2. Ensure the random module is initialized
Initialization of the random module only needs to be done one time for a given
Context instance to initialize all random number sources defined anywhere in
the source. It is not an error to initialize more than once, but the random
module's state will be reset on the second initialization.
automatically with run_with_args()
This step is automatically done for you if you use run_with_args(), which is
the recommended way to run your model. This allows you to provide a seed for the
random module as a command line parameter, among other conveniences.
manually with run_with_args()
Even if you are using run_with_args(), you might still wish to
programmatically initialize the Context with a hard-coded random seed, say,
during development.
fn main() {
run_with_args(|context, _, _| {
context.init_random(42);
Ok(())
})
.unwrap();
}
This re-initializes the random module with the provided seed.
manually on Context
In cases such as in unit tests, you can initialize the random module manually:
let mut context = Context::new();
// Initialize with a seed for reproducibility.
context.init_random(42);
3. Sampling random numbers using the RNG
The random module endows Context with
several convenient member functions
you can use to do different kinds of sampling. (The next section covers
several.) The key is to always provide the name of the RNG we defined in step 1.
We called it TransmissionRng in our example.
let random_integer = context.sample_range(TransmissionRng, 0..100);
Make sure the random module's context extension is imported if your IDE does
not automatically add the import for you:
use ixa::prelude::*;
Some typical uses of sampling
Ixa provides several methods for generating random values through the context:
Sample from a Range
let random_person = context.sample_range(MyRng, 0..POPULATION);
Sample Boolean with Probability
let result = context.sample_bool(MyRng, 0.4); // 40% chance of true
Sample from a Distribution
Ixa re-exports the rand crate as ixa::rand. The rand crate comes with
a few common distributions.
The rand_distr and the
statrs crates
provide many more.
let value = context.sample_distr(MyRng, Uniform::new(2.0, 10.0).unwrap());
`rand` crate version compatibility
As of this writing, the latest version of the rand crate is v0.9.2, but some
popular crates in the Rust ecosystem still use rand@0.8.*, which is
incompatible. Using an incompatible version in your code can result in
confusing errors that end with the tell-tale line, "Note: there are multiple
different versions of crate rand in the dependency graph". Always using
rand that Ixa re-exports at ixa::rand will help you avoid this trap.
Custom Random Generation
For more sophisticated sampling use cases, you might need direct access to the
random number generator. In this case, we use a design pattern that ensures we
have safe access to the RNG: we pass a closure to the context.sample(...)
method that takes the RNG as an argument. If we didn't already have
context.sample_weighted(...), we could do the following:
let choices = ['a', 'b', 'c'];
let weights = [2, 1, 1];
let choice = context.sample(MyRng, |rng|{
let dist = WeightedIndex::new(&weights).unwrap();
choices[dist.sample(rng)]
});
Reports In Depth
Syntax Summary and Best Practices
// Create a struct for a row of the report; must implement `Serialize`.
#[derive(Serialize)]
struct ReportItem {
// Arbitrary (serializable) data fields.
}
// Define the report item in ixa.
define_report!(ReportItem);
// Somewhere during context initialization, initialize the report:
context.add_report::<ReportItem>("my_report")?;
// To write a row to the report:
context.send_report(
ReportItem {
// A concrete ReportItem instance
}
);
Best practices:
- Only record as much data as you require, because gathering and writing data takes computation time, the data itself takes up space, and post-processing also takes time.
- In particular, avoid a "snapshot the universe" approach to recording simulation state.
- Balance the amount of aggregation you do inside your simulation with the amount of aggregation you do outside of your simulation. There are trade-offs.
- Do not use the
-f/--force-overwriteflag in production to avoid data loss. - Use indexes / multi-indexes for reports that use queries. (See the chapter on Indexing.)
Introduction
Ixa reports let you record structured data from your simulation while it runs. You can capture events, monitor population states over time, and generate summaries of model behavior. The output is written to CSV files, making it easy to use external tools or existing data analysis pipelines.
The report API takes care of a lot of details so you don't have to.
- One file per report type - Each report you define creates its own CSV file
- Automatic headers - Column names are derived from your report structure
- Hook into global configuration - Control file names, prefixes, output directories, and whether existing output files are overwritten using a configuration file or ixa's command line arguments
- Streaming output - Data is written incrementally during simulation execution
There is also built-in support for reports based on queries and periodic reports that record data at regular intervals of simulation time.
Configuring Report Options
You can configure the reporting system using the report_options() method on
Context. The configuration API uses the builder pattern to configure the
options for all reports for the context at once.
use ixa::prelude::*;
use std::path::PathBuf;
let mut context = Context::new();
context
.report_options()
.file_prefix("simulation_".to_string())
.directory(PathBuf::from("./output"))
.overwrite(true);
The configuration options are:
| Method | Description |
|---|---|
file_prefix(String) | A prefix added to all report filenames. Useful for distinguishing different simulation runs or scenarios. |
directory(PathBuf) | The directory where CSV files will be written. Defaults to the current working directory. |
overwrite(bool) | Whether to overwrite existing files with the same name. Defaults to false to prevent accidental data loss. |
Note that all reports defined in the Context share this configuration.
This configuration is also used by other outputs that integrate with report
options, such as the profiling JSON written by
ixa::profiling::ProfilingContextExt::write_profiling_data().
Case Study 1: A Basic Report
Let's imagine we want a basic report that records every infectiousness status
change event (see examples/basic-infection/src/incidence_report.rs). The first
few rows might look like this:
| time | person_id | infection_status |
|---|---|---|
| 0.0 | 986 | I |
| 0.001021019741165120 | 373 | I |
| 0.013085498308028700 | 338 | I |
| 0.02134601331583040 | 542 | I |
| 0.02187737003255150 | 879 | I |
| ⋮ | ⋮ | ⋮ |
As far as ixa's report system is concerned, we really only need four ingredients for a simple report:
- A report item type that will represent one row of data in our report,
basically anything that implements
serde::Serialize. - A
define_report!macro invocation declaring the report item is for a report. - A call to
context.add_report(), which readies the output file for writing. This also establishes the filename associated to the report for the report item according to yourContext's report configuration. (See the section "Configuring Report Options" for details.) - One or more calls to
context.send_report(), which write lines of data to the output file.
But even for very simple use cases like this one, we will need to "wire up" simulation events, say, to our data collection. Here is what this might look like in practice:
// initialization_report.rs
use ixa::prelude::*;
use serde::{Deserialize, Serialize};
// ...any other imports you may need.
/// 1. This struct will represent a row of data in our report.
#[derive(Serialize)]
struct IncidenceReportItem {
/// The simulation time (in-universe time) the transition took place
time: f64,
/// The ID of the person whose status changed
person_id: PersonId,
/// The new status the person transitioned to
infection_status: InfectionStatusValue,
}
/// 2. Tell ixa that we want a report for `IncidenceReportItem`.
define_report!(IncidenceReportItem);
/// This and other auxiliary types would typically live in a different
/// source file in practice, but we emphasize here how we are "wiring up"
/// the call to `context.send_report()` to the change in a person property.
type InfectionStatusEvent = PersonPropertyChangeEvent<InfectionStatus>;
/// We will want to ensure our initialization function is called before
/// starting the simulation, so let's follow the standard pattern of having
/// an `init()` function for our report module, called from a main
/// initialization function somewhere.
pub fn init(context: &mut Context) -> Result<(), IxaError> {
/// 3. Prepare the report for use. This gives the report the *short name*
/// `"incidence"`.
context.add_report::<IncidenceReportItem>("incidence")?;
/// In our example, we will record each transition of infection status
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
Ok(())
}
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
/// 4. Writing a row to the report is as easy as calling
/// `context.send_report` with the data you want to record.
context.send_report(
IncidenceReportItem {
time: context.get_current_time(),
person_id: event.person_id,
infection_status: event.current,
}
);
}
This report is event driven: an InfectionStatusEvent triggers the creation of
a new row in the report. But do we really want to record every change of
infection status? Suppose what we actually care about is transitions from
susceptible to infected. In that case we might modify the code as follows:
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
/// 4. Only write a row if a susceptible person becomes infected.
if (InfectionStatusValue::Susceptible, InfectionStatusValue::Infected)
== (event.previous, event.current)
{
context.send_report(
IncidenceReportItem {
time: context.get_current_time(),
person_id: event.person_id,
infection_status: event.current,
}
);
}
}
Report Design Considerations
Separation of Concerns
Notice that we use a property change event to trigger writing to the report in
the example of the previous section. We could have done it differently: Instead
of subscribing an even handler to a property change event, we could have made
the call to context.send_report directly from whatever code changes a person
from "susceptible" to "infected". But this is a bad idea for several reasons:
- Separation of Concerns & Modularity: The transmission manager, or whatever code is responsible for changing the property value, should not be burdened with responsibilities like reporting that are outside of its purview. Likewise, the code for the report exists in a single place and has a single responsibility.
- Maintainability: Putting the call to
context.send_reportwith the code that makes the property change implicitly assumes that that is the only way the property will be changed. But what if we modify how transmission works? We would have to remember to also update every single affected call tocontext.send_report. This explosion in complexity is exactly the problem the event system is meant to solve.
Data Aggregation
You have to decide what data to include in the report and when to collect it. To determine the data sets you need, work backwards from what kinds of analysis and visualizations you will want to produce. It is best to avoid over-printing data that will not be used downstream of the simulation process. Often the most important design question is:
How much aggregation do you do inside the model versus during post-processing after the fact?
Some of the trade-offs you should consider:
- Aggregation in Rust requires more engineering effort. You generally need to work with types and container data structures, which might be unfamiliar to programmers coming from dynamic languages like Python.
- Aggregation in Rust generally executes much faster than post-processing in Python or R.
- In the model you have access to the full context of the data, including input
parameters and person properties—all system state—at the time you are
recording the data. Consequently:
- you can do more sophisticated filtering of data;
- you can do computation or processing using the full context that might be difficult or impossible after the fact.
- Data processing in Python is easy to do and possibly a pre-existing skillset for the model author.
- Relying on post-processing might require very large datasets, possibly many gigabytes, which requires both disk space and processing time.
Case Study 2: A Report With Aggregation
In the Ixa source repository you will find the basic-infection example in the
examples/ directory. You can build and run this example with the following
command:
cargo run --example basic-infection
The incidence report implemented in
examples/basic-infection/src/incidence-report.rs is essentially the report of
the section Case Study 1: A Basic Report above, which records the current
time, PersonId, and infection status every time there is a change in a
person's InfectionStatus property. This obviously results in 2 × 1000 =
2000 rows of data, twice the population size, since each person makes two
transitions in this model.
But suppose what we really want is to plot the count of people having each
InfectionStatusValue at the end of each day over time.
We can easily compute this data from the existing incidence report in a post-processing step. But a more efficient approach is to do the aggregation within the model so that we write only exactly the data we need.
| time | susceptible_count | infected_count | recovered_count |
|---|---|---|---|
| 0.0 | 999 | 1 | 0 |
| 1.0 | 895 | 92 | 13 |
| 2.0 | 811 | 157 | 32 |
| 3.0 | 737 | 193 | 70 |
| 4.0 | 661 | 226 | 113 |
| ⋮ | ⋮ | ⋮ | ⋮ |
The MAX_TIME is set to 300 for this model, so this will result in only 301
rows of data (counting "day 0").
Aggregation
We could count how many people are in each category every time we write a row to the report, but it is much faster and more efficient to just keep track of the counts. We use a data plugin for this purpose:
struct AggregateSIRDataContainer {
susceptible_count: usize,
infected_count: usize,
recovered_count: usize,
}
define_data_plugin!(AggregateSIRData, AggregateSIRDataContainer,
AggregateSIRDataContainer {
susceptible_count: 0,
infected_count: 0,
recovered_count: 0,
}
);
We need to initialize the susceptible_count in an init function:
pub fn init(context: &mut Context) {
// Initialize `susceptible_count` with population size.
let susceptible = context.get_current_population();
let container = context.get_data_mut(AggregateSIRData);
container.susceptible_count = susceptible;
// ...
}
And we need to update these counts whenever a person transitions from one category to another:
fn handle_infection_status_change(context: &mut Context, event: InfectionStatusEvent) {
match (event.previous, event.current) {
(InfectionStatusValue::S, InfectionStatusValue::I) => {
// A person moved from susceptible to infected.
let container = context.get_data_mut(AggregateSIRData);
container.susceptible_count -= 1;
container.infected_count += 1;
}
(InfectionStatusValue::I, InfectionStatusValue::R) => {
// A person moved from infected to recovered.
let container = context.get_data_mut(AggregateSIRData);
container.infected_count -= 1;
container.recovered_count += 1;
}
(_, _) => {
// No other transitions are possible.
unreachable!("Unexpected infection status change.");
}
}
}
We need to wire up this event handler to the InfectionStatusEvent in the
init function:
pub fn init(context: &mut Context) {
// Initialize `susceptible_count` with population size....
// Wire up the `InfectionStatusEvent` to the handler.
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
// ...
}
That is everything needed for the bookkeeping.
aggregation inside the model
The aggregation step often doesn't look like aggregation inside the model, because we can accumulate values as events occur instead of aggregating values after the fact.
Reporting
Now we tackle the reporting. We need a struct to represent a row of data:
// The report item, one row of data.
#[derive(Serialize)]
struct AggregateSIRReportItem {
time: f64,
susceptible_count: usize,
infected_count: usize,
recovered_count: usize,
}
// Tell ixa it is a report item.
define_report!(AggregateSIRReportItem);
Now we initialize the report with the context, and we add a periodic plan to
write to the report at the end of every day. The complete init() function is:
pub fn init(context: &mut Context) {
// Initialize `susceptible_count` with population size.
let susceptible = context.get_current_population();
let container = context.get_data_mut(AggregateSIRData);
container.susceptible_count = susceptible;
// Wire up the `InfectionStatusEvent` to the handler.
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
// Initialize the report.
context.add_report::<AggregateSIRReportItem>("aggregate_sir_report")
.expect("Failed to add report");
// Write data to the report every simulated day.
context.add_periodic_plan_with_phase(
1.0, // A period of 1 day
write_aggregate_sir_report_item,
ExecutionPhase::Last // Execute the plan at the end of the simulated day.
);
}
The implementation of write_aggregate_sir_report_item is straightforward: We
fetch the values from the data plugin, construct an instance of
AggregateSIRReportItem, and "send" it to the report.
fn write_aggregate_sir_report_item(context: &mut Context) {
let time = context.get_current_time();
let container = context.get_data_mut(AggregateSIRData);
let report_item = AggregateSIRReportItem {
time,
susceptible_count: container.susceptible_count,
infected_count: container.infected_count,
recovered_count: container.recovered_count,
};
context.send_report(report_item);
}
Exercise:
- The
aggregate_sir_reportis 301 lines long, one row every day untilMAX_TIME=300, but most of the rows are of the form#, 0, 0, 1000, because the entire population is recovered long before we reachMAX_TIME. This is pretty typical of periodic reports—you get a lot of data you don't need at the end of the simulation. Add a simple filter towrite_aggregate_sir_report_itemso that theaggregate_sir_reportonly contains the data we actually want, about ~90 rows (the first ~90 days). Don't just filter on the day, because the "last" day can change with a different random seed or changes to the model.
Migration to ixa 2.0
Properties in the new Entity system
In the new Entity system, properties work a little differently.
The Old Way to Define a Property
Previously a property consisted of two distinct types: the type of the property's value, and the type that identifies the property itself.
// The property _value_ type, any regular Rust datatype that implements
// `Copy` and a few other traits, which we define like any other Rust type.
#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub enum InfectionStatusValue {
Susceptible,
Infected,
Recovered,
}
// The type identifying the property itself. A macro defines this as a ZST and
// generates the code connecting it to the property value type above.
define_person_property_with_default!(
InfectionStatus, // Property Name
InfectionStatusValue, // Type of the Property Values
InfectionStatusValue::Susceptible // Default value used when a person is added to the simulation
);
The only entity in the old system is a person, so there's no need to specify
that this is a property for the Person entity.
The New Way to Define a Property
In the new system, we combine these two types into a single type:
// We now have an `Entity` defined somewhere.
define_entity!(Person);
// This macro takes the entire type declaration itself as the first argument.
define_property!(
enum InfectionStatus {
Susceptible,
Infected,
Recovered,
},
Person,
default_const = InfectionStatus::Susceptible
);
If you want to use an existing type for a property, or if you want to make the
same type a property of more than one Entity, you can use the impl_property!
variant:
// The downside is, we have to make sure the property type implements all the
// traits a property needs.
#[derive(Copy, Clone, Debug, PartialEq, Serialize)]
pub enum InfectionStatus {
Susceptible,
Infected,
Recovered,
}
// Implements `Property<Person>` for an existing type.
impl_property!(
InfectionStatus,
Person,
default_const = InfectionStatus::Susceptible
);
(In fact, the only thing define_property! does is tack on the derive
traits and pub visibility to the type definition and then call
impl_property! as above.)
The crucial thing to understand is that the value type is the property type.
The impl Property, which the macros give you, is
the thing that ties the InfectionStatus type to the Person entity.
For details about defining properties, see the property_impl module-level docs
and API docs for the macros define_property!, impl_property!,
define_derived_property!, impl_derived_property!, and
define_multi_property!. The API docs give multiple examples.
Summary
| Concept | Old System | New System | Notes / Implications |
|---|---|---|---|
| Property Type Structure | Two separate types: (1) value type, and (2) property-identifier ZST. | A single type represents both the value and identifies the property. | Simplified to a single type |
| Defining Properties | Define a normal Rust type (or use an existing primitive, e.g. u8), then use a macro to define the identifying property type. | Define a normal Rust type, them use impl_property to declare it a Property for a particular Entity E. | |
| Entity Association | Implicit—only one entity ("person") | Every property must explicitly specify the Entity it belongs to (e.g., Person); Entities are defined separately. | |
| Default Values | Provided in the macro creating the property-identifier type. | Same but with updated syntax; default values are per Property implementation. | |
| Using Existing Types | A single value type can be used in multiple properties—including primitive types like u8 | Only one property per type (per Entity); primitive types must be wrapped in a newtype. | Both systems require that the existing type implement the required property traits. |
| Macro Behavior | Macros define the property’s ZST and connect it to the value type via trait impls. | Macros define "is a property of entity E" relationship via trait impl. No additional synthesized types. | Both enforce correctness via macro |
New Entities API: How Do I...?
We will use Person as an example entity, but there is nothing special about
Person. We could just as easily define a School entity, or an Animal
entity.
Defining a new Entity
Use the ixa::define_entity! macro:
define_entity!(Person);
This both declares the type Person and implements the Entity trait for it.
If you want to implement the Entity trait for a type you have already
declared, use the impl_entity! macro instead:
#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)]
pub struct Person;
impl_entity!(Person);
These macros automatically create a type alias of the form
MyEntityId = EntityId. In our case, it defines the type alias
PersonId = EntityId.
Adding a new entity (e.g. a new person)
Adding a new entity with multiple property values:
// Assuming the `Person` entity is defined somewhere.
// Add a new entity (a person in this case) to an existing `Context` instance we have access to.
let person_id = context.add_entity((Age(25), InfectionStatus::Infected)).unwrap();
Observe:
- The compiler is smart enough to know that we are adding a new
Personentity because we supplied a list of property values that are properties for aPerson. - The
add_entityfunction takes a "property list", which is just a tuple of property values. The properties must be distinct, of course, and there must be a value for every "required" property, that is, for every (non-derived) property that doesn't have a default value. - A single-property tuple uses the syntax
(Age(25), ). Notice the awkward trailing comma, which lets the compiler know the parentheses are defining a tuple rather than functioning as grouping an expression.
Adding a new entity with just one property value:
// Assuming the `Person` entity is defined somewhere.
// Add a new entity (a person in this case) to an existing `Context` instance we have access to.
let person_id = context.add_entity((Age(25), )).unwrap();
Adding a new entity with only default property values:
// If you specify the `EntityId<E>` return type, the compiler uses it to infer which entity to add.
// This is a good practice and avoids the special "turbo fish" syntax.
let person_id: PersonId = context.add_entity(()).unwrap();
// If we don't specify the `EntityId<E>` type, we have to explicitly tell the compiler *which* entity
// type we are adding, as there is nothing from which to infer the entity type.
let person_id = context.add_entity::<Person, _>(()).unwrap();
(These two examples assume there are no required properties, that is, that every property has a default value.)
Getting a property value for an entity
// The compiler knows which property to fetch because of the type of the return value we have specified.
let age: Age = context.get_property(person_id);
In the rare situation in where the compiler cannot infer which property to fetch, you can specify the property explicitly using the turbo fish syntax. We recommend you always write your code in such a way that you can use the first version.
let age = context.get_property::<Person, Age>(person_id);
Setting a property value for an entity
// The compiler is always able to infer which entity and property using the `EntityId` and type
// of the property value.
context.set_property(person_id, Age(35));
Index a property
// This method is called with the turbo-fish syntax, because there is no way for the compiler to infer
// the entity and property types.
context.index_property::<Person, Age>();
It is not an error to call this method multiple times for the same property. All calls after the first one will just be ignored.
Subscribe to events
The only difference is that now we use the
PropertyChangeEvent<E: Entity, P: Property and
EntityCreatedEvent<E: Entity> types.
pub fn init(context: &mut Context) {
context.subscribe_to_event(
move |context, event: PropertyChangeEvent<Person, InfectionStatus>| {
handle_infection_status_change(context, event);
},
);
context.subscribe_to_event(move |context, event: PropertyChangeEvent<Person, Alive>| {
handle_person_removal(context, event);
});
}
Appendix: Rust
- Specifying Generic Types and the Turbo Fish
- Rust Learning Resources: The parts of Rust most critical for using Ixa
- Python vs. Rust: A Python/Rust Rosetta Stone
Specifying Generic Types and the Turbo Fish
Rust and ixa in particular make heavy use of type generics, a way of writing a single piece of code, a function for example, for a whole family of types at once. A nice feature of Rust is that the compiler can often infer the generic types at the point the function is used instead of relying on the programmer to specify the types explicitly.
An example with Context::add_entity()
Suppose we want to initialize a population:
define_entity!(Person);
define_property!(
// The type of the property
enum InfectionStatus {S,I,R},
// The entity the property is associated with
Person,
// The property's default value for newly created `Person` entities
default_const = InfectionStatus::S
);
/// Populates the "world" with people.
pub fn init(context: &mut Context) {
for _ in 0..1000 {
context.add_entity((InfectionStatus::S, )).expect("failed to add person");
}
}
During the initialization of our population, we explicitly told ixa to create a
new susceptible person, that is, with the InfectionStatus::S property value.
However, when we defined the InfectionStatus property with the
define_property! macro, we specified a default initial value with
default_const = InfectionStatus::S. Since InfectionStatus has a default
value, we don't need to supply a value for it when calling
context.add_entity(...). But remember, the compiler infers which entity to
create based on the property values we supply, and if we don't supply any
property values, we need another way to specify the entity to create.
The Context::add_entity function is actually a whole family of functions
Context::add_entity<E: Entity, PL: PropertyList>, one function for each
concrete Entity type E and PropertyList type PL. When we call
context.add_entity(...) in our code with a tuple of properties (the
initialization list), the Rust compiler looks at the initialization list and
uses it to infer the concrete types E and PL. But when the initialization
list is PL=() (the empty list), the compiler doesn't know what the Entity
type E should be. The Rust language allows us to tell it explicitly using the
"turbo fish" notation:
context.add_entity::<Person, ()>(()).expect("failed to add person");
Actually, the compiler always already knows the PropertyList type PL, so
we can use the "wildcard" _ (underscore) to tell the compiler to infer that
type itself:
context.add_entity::<Person, _>(()).expect("failed to add person");
There is another way to give the compiler enough information to infer the
Entity type, namely by specifying the return type we are expecting. In our
case, we just throw the returned PersonId away, but suppose we want to refer
to the newly created person. We could write:
let person_id: PersonId = context.add_entity(()).expect("failed to add person");
The Entity type E must be Person, because that is the only way
Context::add_entity<E: Entity, PL: PropertyList> can return a PersonId (a
type alias for EntityId<Person>). You can use this trick even if you never
use the returned PersonId, but in such a case it's best practice to signal
this intent by using the special "don't care" variable _ (underscore):
let _: PersonId = context.add_entity(()).expect("failed to add person");
You do not have to learn the rules for when specifying the types using turbo
fish notation is required. The compiler will let you know. For add_entity,
always specifying the returned type means you'll never have to worry about turbo
fish notation.
Preferred Idiom for Context::sample_entity()
The Context::sample_entity() method especially deserves discussion, because we
often want to immediately use the returned value. If we try to use the standard
Rust idiom to express this, we have to specify the types using turbo fish, which
is awkward and ugly:
// Sample from the entire population by supplying the "empty" query. The last two `_`s are for the query type and
// RNG type, both of which the compiler can infer.
if let Some(person_id) = context.sample_entity::<Person, _, _>(TransmissionRng, ()) {
// Do something with `person_id`...
}
Since we are sampling from the entire population, if sample_entity returns
None, then the population is empty, and we clearly have a bug in our code, in
which case the best thing to do is to crash the program and fix the bug. Thus,
instead of the if let Some(...) = construct, it's actually better to just call
unwrap on the returned value in this case. Here is a much more readable and
simple way to write the code:
// Sample from the entire population by supplying the "empty" query. The compiler infers which entity to sample
// from the type of the variable we assign to.
let person_id: PersonId = context.sample_entity(()).unwrap();
// Do something with `person_id`...
If you really want to check for the None case in your code, assign the return
value to a variable of type Option<PersonId> instead of immediately
unwrapping the PersonId value. Then you can use if let Some(...) = or a
match statement at your preference:
let maybe_person_id: Option<PersonId> = context.sample_entity(());
match maybe_person_id {
Some(person_id) => {
// Do something with `person_id`
}
None => {
// Handle the empty population case
}
}
Other Examples
The compiler's ability to infer the types of generic functions means that for most of the common functions the types do not need to be specified with turbo fish notation:
// The compiler "magically" knows to use the `get_property` method that fetches
// `InfectionStatus` because of the type of the variable being assigned to.
let status: InfectionStatus = context.get_property(person_id);
// Explicit types are almost never required for `Context::set_property`, because
// the types of the entity ID and property value are almost always already known.
context.set_property(other_person_id, status);
As with Context::add_entity, the generic types for querying and sampling
methods can usually be inferred by the compiler except when the "empty" query is
provided:
// A silly example, but no turbo fish is required.
context.with_query_results(
(Age(30), Alive(true)),
|people_set| println("{:?}", people_set)
);
A few methods always require the user to specify the generic type when they are called:
// Which entity are we counting? Here the return type is always `usize`.
let population: usize = context.get_entity_count::<Person>();
// Specify which property of which entity you'd like to index.
context.index_property::<Person, Age>();
// Specify the report to add.
context.add_report::<IncidenceReportItem>("incidence")?;
// Specify the event to subscribe to.
context.subscribe_to_event::<InfectionStatusEvent>(handle_infection_status_change);
In the last example above, the concrete type we specify is actually a type alias:
pub type InfectionStatusEvent = PropertyChangeEvent<Person, InfectionStatus>;
While it is not strictly necessary to define this type alias, you can see that the notation gets rather gnarly without it:
context.subscribe_to_event::<PropertyChangeEvent<Person, InfectionStatus>>(handle_infection_status_change);
Learning Rust for Ixa
Why Rust?
We designed Ixa to be efficient for computers and for people. Rust provides the speed and memory characteristics necessary for large-scale, computationally-intensive simulations while still being relatively friendly to work with. That being said, if you are used to working in a higher-level language like R or Python, Rust can take some time to learn. We recommend taking a look at the The Rust Book for a comprehensive overview. Here are a few features that might be new to you:
-
Rust has a strict model of how it uses your computer memory, called ownership. In practice, this means that you can only manipulate objects in specific ways. This helps code actually do what you think it is doing and has been shown to reduce long-term bugs. Rust's ownership rules are enforced at compile-time, which helps you find errors in your code earlier. When developing ABMs with Ixa, these capabilities help us more easily reason about complicated model logic and ensure that plugins interact modularly.
-
Rust has a cohesive ecosystem of all the tools you will need for development: it has a built-in package/project manager (
cargo), a built-in linter (clippy), a built-in environment manager for updating Rust versions so that you can manage multiple simultaneously (rustup), and a global repository for all packages. Rust provides a complete ecosystem for all your model development needs, and we find this centralization useful for ensuring robust version control and reproducibility of code across different users and computers. This also means that chances are someone has built a crate for a problem you may be troubleshooting. -
Rust is a strongly-typed language, meaning that the type of each variable must be known at compile-time and cannot be changed after variable definition. However, Rust also has automatic type inference and other ergonomic features to help make writing code more seamless and efficient. As a result, you only need to specify the type of a variable in cases where it is ambiguous while still taking advantage of the rigidity of static typing to ensure your code is doing what you think it is doing. We find the combination of static typing with other Rust features for quick prototyping valuable for writing models and tests simultaneously.
Common Rust patterns in Ixa modeling
The Rust Book remains the best resource to learn Rust, and we recommend reading the book in its entirety. However, the book emphasizes concepts that are less prevalent in day-to-day Ixa use (for instance, ownership), and certain patterns that pop up a lot are less emphasized. Below, we include sections of the book that are particularly important.
-
Chapter 5: Structs: If you are new to object-oriented programming, this chapter introduces the "object-oriented"-ness of Rust. Rust has elements of both an object-oriented and functional programming language, but structs come up often when writing an Ixa model, and thinking about Ixa as being an object-oriented framework is useful for evaluating the kinds of data you need in your model and how to use the data.
-
Chapter 6: Enums: Enums, match statements, and storing data in enum variants are all distinctly Rust patterns. If you have previously worked with Haskell or OCaml, these objects may be familiar to you. Nevertheless, enums come up frequently in Ixa, in particular with person properties (e.g., a person's infection status is a value of an enum with variants
Susceptible,Infected,Recovered), and seeing how you can store information in each of these variants helps modelers understand how to best store the data they need for their particular use case. -
Chapter 10, Section 2: Traits: If you read only one section, let this be it. Traits are the cornerstone of Ixa's modular nature. They are used in at least two ways throughout the Ixa codebase. First, the methods that make up higher-level abstractions -- like
PropertyorEvent-- are organized into traits, giving them shared features so that calling code can use these methods without knowing the exact underlying type. For instance, your model might define a trait to specify interventions that impact the probability of transmission -- like facemasks or hazmat suits. Regardless of the exact nature of the intervention, your model wants to call some methods on the transmisison modifier, like returning its efficacy. If these methods are grouped into theTransmissionModifiertrait, they can be implemented on any type and used wherever the code needs a transmission modifier without depending on the underlying type. Secondly, traits implemented onContext(called "Context extensions" in Ixa) are the primary way of new modules adding a public interface so that their methods can be called from other modules. For instance, theContextPeopleExttrait extension provides methods relating to people and their properties. Rust traits are a supercharged version of "interfaces" in other programming languages, and thinking in terms of traits will help you write modular Ixa code. -
Chapter 13, Section 1: Closures: Ixa often requires the user to specify a function that gets executed in the future. This chapter goes over the mechanics for how anonymous functions are specified in Rust. Although the syntax is not challenging, this chapter discusses capturing and moving values into a closure, type inference for closure arguments, and other concepts specific to Rust closures. You may see the word "callback" referred to in the Ixa documentation -- callbacks are what Ixa calls the user-specified closures that are evaluated when executing plans or handling events.
In addition,
Chapter 19: Patterns
reiterates the power of enum variants and the match statement in Rust. If you
find yourself writing more advanced Rust code, Chapters
9: Error Handling,
18: Object-oriented programming,
and
20: Advanced Features
include helpful tips as you dive into more complicated Ixa development.
If you find yourself writing more analysis-focused code in Rust, Chapters 9: Error Handling and 13.2: Iterators include helpful tools for writing code reminiscent of Python.
On Ownership
Rust's signature feature is its ownership rules. We tried to design Ixa to handle ownership internally, so that users rarely have to think about the specific Rust rules when interfacing with Ixa. Nevertheless, understanding ownership rules is valuable for debugging more complicated Ixa code and understanding what you can and cannot do in Rust. There are excellent resources for learning Rust's ownership rules available online, but at a high-level, here are the key ideas to understand:
- When a function takes an object as input, it takes ownership of that object
-- meaning that the object is "consumed" by the function and does not exist
after the calling of that function. - This is true for all types except those
that are automatically copyable, or in Rust lingo implement the
Copytrait. All primitive types -- floats, integers, booleans, etc. -- implementCopy, meaning that this rule is most commonly felt with vectors and hashmaps. These are examples of types that do not implementCopy-- in this case, that is because their size dynamically changes as data is added and removed, so Rust stores them in a part of the memory optimized for changing sizes rather than fast access and copying. - References (denoted by an
&in front of an object like&Object) allow functions to have access to the object without taking ownership of it. Therefore, we often return references to an object so that we do not have to give up explicit ownership of that object, such as when we want to get the value of data owned centrally by the simulation (ex., model parameters) in a particular module. - There are two kinds of references -- mutable references
&mut Objectand immutable references&Object. Depending on what kind of reference you have, you can do one of two kinds of things. If you have an active immutable reference to an object, you can take any number of additional immutable references to the object. But, if you have a mutable reference to an object, you can only ever have that one mutable reference to the object be active. This is because an active immutable reference to the object would also have changed as the mutable reference is mutated, and Rust can no longer make guarantees about the memory to which the immutable reference points.
In practice, #3 often requires the most troubleshooting to get around. Sometimes, a refactor of your code can help circumvent ownership challenges.
Ixa tutorials
Within the Ixa repo, we have created some examples. Each example has a readme that walks the user through a toy model and what it illustrates about Ixa's core functionality. To run the examples, from the repo root, just specify the name of the example:
cargo run --example {name-of-example}
In general, you will be using cargo to run and interact with your Ixa models
from the command line. We recommend learning some
basic cargo commands, and
there are valuable
cheatsheets
to keep handy as you get more involved in active development.
Here are a few other useful commands to know:
cargo testwill run all tests in the project.cargo build --releasecompiles the project into a shell executable that you can ship to your users.cargo add {crate-name}adds a Rust crate/project dependency to your project for you to use.
Additional Rust resources
- Rust By Example: "RBE shows off a bunch of code, and keeps the talking to a minimum. It also includes exercises!"
- Tour of Rust: Live code and explanations, side by side.
- Rust in Easy English: 60+ concepts, simple English, example-driven.
- Rust for the Polyglot Programmer: A guide for the experienced programmer.
- The Rust Standard Library Documentation
- The Cargo Book: An online manual for Rust's package manager Cargo.
- The Rust Playground: Execute sample code in your browser.
Python vs. Rust
Many features of the Rust language will be familiar to Python programmers. Many examples are listed below. For more code comparisons, see py2rs.
Python
Rust
my_tuple = (1, 2, 3)
# Access the element at index 1
n = my_tuple[1]
let my_tuple = (1, 2, 3);
// Access the element at index 1
let n = my_tuple.1;
(x, y, z) = my_tuple
let (x, y, z) = my_tuple;
(a, _, b) = my_tuple
let (a, _, b) = my_tuple;
# Lambdas are anonymous functions
f = lambda x: x * x
f(4)
// Closures are anonymous functions
let f = | x | x * x;
f(4)
x: int = 42
let x: i32 = 42;
range(0, 10)
0..10
match x:
case 1:
...
case _:
...
match x {
1 => { ... },
_ => { ... }
}
for i in range(0, 10):
...
for i in 0..10 {
...
}
if x > 10:
...
else:
...
if x > 10 {
...
} else {
...
}
while x < 10:
...
while x < 10 {
...
}
import math
use std::f64::consts::PI;
"Hello, {}".format(name)
format!("Hello, {}", name)
lst = [1, 2, 3]
let lst = vec![1, 2, 3];