Framework design
Here we describe the design principles of the AlgebraicAgents. It should be of most use to advanced users and persons interested in contributing to the software. New users are encouraged to start by reading one of the tutorials ("sketches").
Simulation loop
We describe here the main simulation loop which steps models built in AlgebraicAgents forward in time.
AlgebraicAgents keeps agents synchronized by ensuring that the model will only simulate the agent(s) whose projected time (e.g. the maximum time for which that agent's trajectory has been solved) is the minimum of all agents. For example, if there are 3 agents, whose internal step sizes are of 1, 1.5, and 3 time units, respectively, then at the end of the first step, their projected time will be 1, 1.5, and 3 (assuming they all start at time 0). The simulation will find the minimum of those times and only simulate the agent(s) whose projected time(s) are equal to the minimum. In this case, it is the first agent, who is now projected to time 2 on the second step (other agents are not simulated). Now, the new minimum time is 1.5, and the second agent is projected to time 3 on the third step, etc. The simulation continues until all agents have been projected to their final time point, or the minimum of projected times reaches a maximum time horizon. If all agents take time steps of the same size, then they will all be updated each global step.
There are several functions in the interface for an AbstractAlgebraicAgent
which implement these dynamics. When defining new agent types, one should implement the AlgebraicAgents._step!
method, which will step that agent forward if its projected time is equal to the least projected time, among all agents in the hierarchy. Agent types also need to implement AlgebraicAgents._projected_to
, which is crucial to keeping the simulation synchronized. It will return:
nothing
if the agent does not implement its own_step!
rule (e.g.FreeAgent
which is a container of agents)true
if the agent has been projected to the final time point (_step!
will not be called again)- a value of
Number
, giving the time point to which the agent has been projected
These are collected into ret
, which is an object that will be true
if and only if all agents have returned true
, and is otherwise the minimum of the numeric values (projection times) returned from each inner agent's step.
Above we show a caricature of the main simulation loop. "Enter program" corresponds to the call to simulate
, the value of ret
is (typically) initialized to 0.0
. The simulation continues to step while ret
is not true
(meaning the maximum time horizon has been reached by the slowest agent), or has not exceeded some maximum.
The inner area enclosed by a dashed border represents where program control is given to the step!
method. The root agent applies _prestep!
recurvisely to all of its inner (enclosed) agents. After this, step!
is then applied to all inner agents, and ret
is updated by each of them. Then the agent applies its own local update _step!
if its own projected time is equal to the minimum of all inner agent projected times (not shown). Then the Opera module for additional interactions is called for the root agent.
Opera
The Opera system allows interactions between agents to be scheduled. By default, AlgebraicAgents.jl provides support for three types of interactions:
- futures (delayed interactions)
- system controls
- instantious interactions
For more details, see the API documentation of Opera
and our tests.
Futures
You may schedule function calls, to be executed at predetermined points of time. An action is modeled as a tuple (id, call, time)
, where id
is an optional textual identifier of the action and call
is a (parameterless) anonymous function, which will be called at the given time
. Once the action is executed, the return value with corresponding action id and execution time is added to futures_log
field of Opera
instance.
See add_future!
and @future
.
Example
alice = MyAgentType("alice")
interact = agent -> wake_up!(agent)
@future alice 5.0 interact(alice) "alice_schedule"
The solver will stop at t=5
and call the function () -> interact(alice)
(a closure is taken at the time when @future
is invoked). This interaction is identified as "alice_schedule"
.
Control Interactions
You may schedule control function calls, to be executed at every step of the model. An action is modeled as a tuple (id, call)
, where id
is an optional textual identifier of the action, and call
is a (parameterless) anonymous function. Once the action is executed, the return value with corresponding action id and execution time is added to controls_log
field of Opera
instance.
See add_control!
and @control
.
Example
system = MyAgentType("system")
control = agent -> agent.temp > 100 && cool!(agent)
@control system control(system) "temperature control"
At each step, the solver will call the function () -> control(system)
(a closure is taken at the time when @future
is invoked).
Instantious Interactions
You may schedule additional interactions which exist within a single step of the model; such actions are modeled as named tuples (id, priority=0., call)
. Here, call
is a (parameterless) anonymous function.
They exist within a single step of the model and are executed after the calls to _prestep!
and _step!
finish, in the order of the assigned priorities.
In particular, you may schedule interactions of two kinds:
poke(agent, priority)
, which will translate into a call() -> _interact!(agent)
, with the specified priority,@call opera expresion priority
, which will translate into a call() -> expression
, with the specified priority.
Examples
# `poke`
poke(agent, 1.) # call `_interact!(agent)`; this call is added to the instantious priority queue with priority 1
# `@call`
bob_agent = only(getagent(agent, r"bob"))
@call agent wake_up(bob_agent) # translates into `() -> wake_up(bob_agent)` with priority 0
Wires
It is possible to explicitly establish oriented "wires" along which information flows between different agents in a hierarchy. Note that it is possible to retrieve and modify the state of any other agent from within any agent, in any way. However, in some cases, it may be desirable to explicitly specify that certain agents observe a particular state variable of another agent.
Consider the following example. First, we set up the hierarchy.
alice = MyAgentType("alice")
alice1 = MyAgentType("alice1")
entangle!(alice, alice1)
bob = MyAgentType("bob")
bob1 = MyAgentType("bob1")
entangle!(bob, bob1)
joint_system = ⊕(alice, bob, name = "joint system")
agent joint system with uuid 8b70215e of type FreeAgent
inner agents:
agent alice with uuid cff592c1 of type Main.MyAgentType
inner agents: alice1
agent bob with uuid cfa9ba67 of type Main.MyAgentType
inner agents: bob1
We then add the wires. Note that the agents connected by a wire can be specified using the respective agent objects, relative paths, or their UUIDs.
Additionally, you can assign names to the edges of a wire (which are nothing
by default). These names can subsequently be used to fetch the information incoming along an edge, a process that we will describe below.
add_wire!(joint_system; from=alice, to=bob, from_var_name="alice_x", to_var_name="bob_x")
add_wire!(joint_system; from=bob, to=alice, from_var_name="bob_y", to_var_name="alice_y")
add_wire!(joint_system; from=alice, to=alice1, from_var_name="alice_x", to_var_name="alice1_x")
add_wire!(joint_system; from=bob, to=bob1, from_var_name="bob_x", to_var_name="bob1_x")
4-element Vector{NamedTuple{(:from, :from_var_name, :to, :to_var_name), <:Tuple{AbstractAlgebraicAgent, Any, AbstractAlgebraicAgent, Any}}}:
(from = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "alice_x", to = Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, to_var_name = "bob_x")
(from = Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "bob_y", to = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, to_var_name = "alice_y")
(from = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "alice_x", to = Main.MyAgentType{name=alice1, uuid=9e54e426, parent=Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}}, to_var_name = "alice1_x")
(from = Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "bob_x", to = Main.MyAgentType{name=bob1, uuid=910a534e, parent=Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}}, to_var_name = "bob1_x")
We list the wires going from and to alice
and alice1
, respectively.
get_wires_from(alice)
2-element Vector{NamedTuple{(:from, :from_var_name, :to, :to_var_name), <:Tuple{AbstractAlgebraicAgent, Any, AbstractAlgebraicAgent, Any}}}:
(from = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "alice_x", to = Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, to_var_name = "bob_x")
(from = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "alice_x", to = Main.MyAgentType{name=alice1, uuid=9e54e426, parent=Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}}, to_var_name = "alice1_x")
get_wires_to(alice1)
1-element Vector{NamedTuple{(:from, :from_var_name, :to, :to_var_name), <:Tuple{AbstractAlgebraicAgent, Any, AbstractAlgebraicAgent, Any}}}:
(from = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "alice_x", to = Main.MyAgentType{name=alice1, uuid=9e54e426, parent=Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}}, to_var_name = "alice1_x")
All the wires within an hierarchy can be retrieved as follows:
getopera(joint_system).wires
4-element Vector{NamedTuple{(:from, :from_var_name, :to, :to_var_name), <:Tuple{AbstractAlgebraicAgent, Any, AbstractAlgebraicAgent, Any}}}:
(from = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "alice_x", to = Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, to_var_name = "bob_x")
(from = Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "bob_y", to = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, to_var_name = "alice_y")
(from = Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "alice_x", to = Main.MyAgentType{name=alice1, uuid=9e54e426, parent=Main.MyAgentType{name=alice, uuid=cff592c1, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}}, to_var_name = "alice1_x")
(from = Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}, from_var_name = "bob_x", to = Main.MyAgentType{name=bob1, uuid=910a534e, parent=Main.MyAgentType{name=bob, uuid=cfa9ba67, parent=FreeAgent{name=joint system, uuid=8b70215e, parent=nothing}}}, to_var_name = "bob1_x")
Given an agent, if getobservable
is implemented for all agents that feed information into the specific agent, we can fetch the values incoming to it.
AlgebraicAgents.getobservable(a::MyAgentType, args...) = getname(a)
retrieve_input_vars(alice1)
Dict{String, String} with 1 entry:
"alice1_x" => "alice"
Additionally, we can plot the agents in the hierarchy, displaying the links between parents and children, along with the wires. The output can be adjusted as needed. Note that it is also possible to export an agent hierarchy as a Mermaid diagram. See typetree_mmd
and agent_hierarchy_mmd
.
In what follows, wiring_diagram
generates a visually appealing Graphviz diagram.
graph1 = wiring_diagram(joint_system)
run_graphviz("gv1.svg", graph1)
# Do not show edges between parents and children.
graph2 = wiring_diagram(joint_system; parentship_edges=false)
run_graphviz("gv2.svg", graph2)
# Only show listed agents.
graph3 = wiring_diagram([alice, alice1, bob, bob1])
run_graphviz("gv3.svg", graph3)
# Group agents into two clusters.
graph4 = wiring_diagram([[alice, alice1], [bob, bob1]])
run_graphviz("gv4.svg", graph4)
# Provide labels for clusters.
graph5 = wiring_diagram([[alice, alice1], [bob, bob1]]; group_labels=["alice", "bob"], parentship_edges=false)
run_graphviz("gv5.svg", graph5)