Actor Model

HASH’s hEngine (which powers hCore and hCloud) is an ultra-fast framework for running large-scale simulations in a distributed fashion. To enable this, an actor model is utilized, and simulations built atop HASH must take this into account. This sets HASH apart from more traditionally object-oriented simulation packages.

What is object-oriented simulation?

Object-oriented programming on the surface looks like a good fit for agent-based simulation of the world (and indeed for many years, given both hardware and data processing constraints, it has arguably been the best approach).

Object-oriented simulations have classes and objects:

  • Classes can be thought of as descriptions or specifications of agents.
  • Objects are instances of classes. Many instances of a single class may exist.

Object-oriented programming requires careful hierarchical defining of agents, abstraction, and subdivision of problems into manageable pieces. For one-shot simulations of static systems, this planning-heavy process is eminently doable, albeit time-consuming.

However, whilst class hierarchies can provide useful means for describing agents, and objects direct ways to perform actions — learning to think in terms of an actor system enables reproducible, large-scale, distributed, asynchronous simulations to be built — which ultimately contain agents who are more easily extensible and representative of the real-world.

An example simulation

To illustrate the key differences between the traditional object-oriented approach to simulations, and the actor model approach taken by HASH, we will compare and contrast an implementation of a simple simulation using each paradigm.

This example simulation consists of several people scattered on a grid, each starting with 1,000 “coins”. On each iteration, agents engage in a bet with one of their neighbors. Both agents in a bet stake an amount of coins equal to 10% of the coins owned by the agent with fewer coins, and the winner is chosen by the flip of a coin.

The code block below shows a traditional object-oriented approach to implementing this simulation. The full implementation is available here.

# Traditional OOP Approach
import random

class Person:
    def __init__(self, position, coins):
        self.position = position
        self.coins = coins
        self.neighbors = []

    def make_bet(self):
        if len(self.neighbors) == 0:
            return
        player = random.choice(self.neighbors)
        stakes = min(self.coins, player.coins) // 10
        result = stakes if random.random() < 0.5 else -stakes
        self.coins += result
        player.coins -= result


def create_scatter_grid(num_people, init_coins, grid_size, search_radius):
    """
    Initialize a grid containing a random scatter of people. 
    A person's neighbors are those people within search_radius
    of their position.
    """
    # Implementation ommited for brevity ...


def run_simulation():
    people = create_people_grid(
        num_people=20,
        init_coins=1000,
        search_radius=5
    )

    num_iterations = 1000
    for i in range(num_iterations):
        for person in people:
            person.make_bet()

Before we consider the actor-based approach, there are a few aspects of the object-oriented implementation which are worth highlighting.

  1. Agents can both see and directly modify the state of other agents. We see this in the make_bet method of the Person class, where the agent updates both their own coin balance and the balance of their neighbor. Two problems arise when we allow agents to directly modify the state of another agent. First, in simulations with many different types of interactions between agents, it is difficult to maintain the complexity of managing agent state when modifications to that state are scattered throughout the codebase. And secondly, while the above implementation executes sequentially on a single thread, extending the object-oriented paradigm to a multi-threaded or distributed architecture requires locking primitives, such as mutexes, on agent state to prevent race conditions. These locking primitives, in turn, add another level of complexity and require careful implementation so as not to introduce further concurrency bugs such as deadlocks.
  2. Agents maintain direct references to their neighbors through the neighbors field in the Person class. If an agent were to be removed from the simulation, agents must somehow be made aware of this change so as not to interact with “dead” agents.
  3. The main simulation loop, as specified by the run_simulation function, is responsible for invoking agent actions. This means that agents cannot asynchronously react to the state of the simulation or to actions intiated by other agents.

What is actor-based simulation?

In the actor model, agents consist of state, have behaviors, and communicate through messaging.

State contains information about an agent. There is no practical limit to how much information can be stored about an agent in its state. You might choose to store information like height, wealth, or occupation on an agent designed to represent a person. Or you might store a list of employees, products offered, or opening-hours on an agent that represents a shop. In geospatial models positional information is typically also included, and social network graph connections are frequently also embedded within state. In contrast to the object-oriented paradigm, only an agent can change its own state.

Behaviors, meanwhile, are the ‘logic’ that drive agents. Without behaviors, agents cannot change their own state, nor do anything else for that matter. Behaviors are stored as a list within an agent’s state, so it is always possible to see what is driving them, and they can be changed during simulation runtime alongside any other part of the state (but only by the agent itself). A key distinction between behaviors and object-oriented methods is that an agent decides which behaviors it executes, and when it does so, during the simulation. This inversion of control, from the top-down object-oriented execution loop, to an agent-local approach allows agents to react asynchronously to the state of the simulation

Messages are how agents communicate with the world around them, and impact their environments. Sending a message alone isn’t enough to modify another agent’s state. That agent has to receive and process the message, as well. Behaviors provide the logic that agents use both to send outbound messages and process inbound messages received.

Actor-based implementation

The same simulation shown in object-oriented form can easily be implemented using the actor-based framework in HASH. This simulation is available to view and run on hIndex. We start by initializing the simulation — specifying which agents should be created and setting their initial state. This may be performed declaratively as shown in init.json below.

[{
  "behaviors": [
    "@hash/create-scatters/create_scatters.js",
    "@hash/create-agents/create_agents.js",
    "@hash/remove-self/remove_self.js"
  ],
  "scatter_templates": [{
    "template_name": "people",
    "template_count": 20,
    "coins": 1000,
    "behaviors": [
      "make_bet.js",
      "@hash/random-movement/random_movement.rs"
    ]
  }]

There a few interesting points of note here. First, agents in hash can create other agents. The simulation is initialized with a single agent which in-turn creates all people agents in the model. Second, behaviors compose with each other. All people agents have two behaviors: make_bet.js and @hash/random-movement/random_movement.rs. The first is a user-defined behavior which allows an agent to engage in bets, and the second allows agents to move throughout the grid as the simulation progresses — one of the many behaviors made available by the community on hIndex.

The make_bet.js behavior is shown below.

// make_bet.js

const behavior = (state, context) => {
  // Check for bets other agents made with me
  context.messages()
    .filter(m => m.type === "coins")
    .forEach(m => state.coins += m.data.count);

  // Make a bet with one of my neighbors
  const neighbors = context.neighbors();
  if (neighbors.length === 0) {
    return;
  }
  const i = Math.floor(Math.random() * neighbors.length);  
  const neighbor = neighbors[i];
  const stakes = Math.floor(Math.min(state.coins, neighbor.coins) / 10);
  const result = Math.random() < 0.5 ? stakes : -stakes;
  state.coins += result;

  // Tell my neighbor to update their coin balance
  state.addMessage(neighbor.agent_id, "coins", {
    count: -result
  });
};

The behavior logic is similar to that of the object-oriented method, but is crucially different in a number of aspects:

  1. An agent gets a read-only view of its neighbors through context.neighbors(). It uses this list to choose which neighbor to engage in a bet with, and updates its own state right away.
  2. An agent cannot modify the state of another agent. Instead, it sends messages to other agents using state.addMessage. All agents are uniquely identified by an ID, and messages are given a type "coins" so that agents can decide how to respond to different messages. An agent can read the messages it has received in the previous iteration through context.messages(), and is free to decide how it should react, or not react, to each message.
  3. The user is not responsible for specifying how the behavior is executed in the simulation loop as in the object-oriented implementation. HASH takes care of this, leaving the user to concentrate on how the agent updates itself during the simulation.

The actor model allows HASH to present a user-friendly and intuitive approach to agent-based modelling. Because only agents can modify their own state, the requirement that object-oriented frameworks place on the user to implement lock-based synchronization is removed, allowing for much more scalable world-building. The very same simulations run locally on multiple cores, and scale seamlessly to large clusters running in the cloud.

For users looking for an approach to class inheritance offered by object-oriented programming, HASH provides Agent Types. Agents in HASH can have any number of types, allowing agent logic to be similarly clustered, inherited, and attached in the form of grouped behaviors.

Quick Jump
What is object-oriented simulation?
An example simulation
What is actor-based simulation?
Actor-based implementation