Source code: garlicsim/misc/step_profile.py
A step profile is a set of world rules to be used in a simulation. For example, if you’re doing a simulation in Physics, you have a bunch of world rules that the simulation adheres to. Forces are computed according to various constants, and then the acceleration is computed by using F = m a. These are the world rules for your simulation. And by using a different step profile, you can use a different set of world rules in the same simulation. For example, do you want to see how your world would look like if you’d change the gravitational constant? Or use a different equation instead of F = m a, so you could switch between Classical Mechanics and Special Relativity? You can do it all by using a different step profile.
Let’s play around with step profiles in the life simpack.
>>> import garlicsim
>>> from garlicsim_lib.simpacks import life
Let’s generate a messy state:
>>> state = life.State.create_messy_root()
>>> state
## # ## # ## # # #### # ###### # ### ###
# # ## # ## ## ## #### # # # # ### #
# # ## #### # # # ## ## # # # ###
### ## ## ## #### ## ## #### ##
##### ### # # ### #### # # # ### ##
# # ## # ### # #### #### # # #
## ## # # # #### ### # # # ## #
# ## # ## #### # # # # ### ## #
### ## # # # #### # ## # # # # # # # #
# # #### # #### ### ## ###### # # # #
# ## ## ## ###### # ## #### # #
# # # # # # # # ####### ### # #
###### ###### ##### ## # ## # ## #
# ##### # # # # # # # ## ######
# ### # # ### # # # # # ## ## ##
### # ## # #### # ### ## # # #
## # #### ### # # ## ### #
## #### # ### # ## ## #### # ## #
# ## # # ###### #### ### #
# # # ## # #### ## ## # ## # ##
### ## #### # ## # # ###### #
## # ## ## ## ##### ## ## # ### # #
# # ## ###### ### ### # ## # ## ##
# # # #### ## ### #### ### # # # ## ##
# ##### # ## # # ## ### ####### ##
The above is a completely random board, from which we will run the Life simulation. Now, if we run Life for 20 iterations, we get something like this:
>>> garlicsim.simulate(state, 20)
## # # # ### #
# # # ## ##
### # #
### ###
## #
# ##
# ## ## # ##
## ## ##
# ###
# ### # ####
## # # ## # # # #
## ## ## # # ### # # # # #
##### # # # # # # # ##
## # ## # #
### ## ## ### # #
#### ## # # #
# # # ## ## # # #
# # ### ## # #
# # # # # ### ## ###
# # # ## # # # # # ###
# # ## ## # ## # #
# ### # ## # #### #
# # ## ## # # #
# # # ### # ###
### # # # # ###
If you looked at a few Life boards in your life (heh,) you’d recognize a few classic Life patterns.
Now, let’s use a different step profile to run the Life simulation under different world laws. Look at the command we’re typing and the output we’re getting:
>>> garlicsim.simulate(state, 20, birth=[0], survival=[1, 2])
#### ## ########## ######## # ########
## ##### ## ######## # ####
####### ##### ############# # ####
#### ##### # ############ # ######
#### # ##### ################ # #######
#### # #### ############### # ### #
#### # ## ############# # # ###### #
#### # ########## # ###### #
###### # # ############# # #### # #
#### # # ############# ##### # #### #
# #### ###################### #### ##
## ########## ######################
#### ########## # ############### ######
#### #### # # # ########### ######
########### ### ## # # ###### ##
########## ###### # ### ###### ## #
#### ########### #### # #
## ## ############ # ####### # # ## #
## # # ### ## # ####### # # #
## # # #### ### ## ####### #
## # #### # ############ ##
# # ######## ## ############# ##### #
# # # ######### ##################### #
# # # ######################### ##########
############## ######## # ##########
This looks totally different than the board we’ve seen before! The simulation was crunched according to different world laws– Specifically, different birth/survival numbers. This is why the board looks totally different. It’s essentially a different world.
What happened there? We gave a couple of keyword arguments, birth and survival, to garlicsim.simulate(), and it affected how the simulation was run. You can probably guess what birth and survival mean in the context of Life simulation: They mean how many live neighbors a cell should have for it to become alive (i.e. birth), or stay alive (i.e. survival.)
But Life is only a case study for us; we want to understand what GarlicSim did here exactly and how we can use arguments in our own simpacks.
Where did the birth and survival arguments came from? Who decided that GarlicSim could run Life simulations under different birth/survival numbers?
Answer: It was all defined in the simpack, in the step function itself.
This is how life‘s step function looks like:
def step_generator(self, birth=[3], survival=[2, 3], randomness=0):
pass # ... Step function's content ...
The step function itself receives these arguments, and then it uses these arguments when creating the new world state.
Default values for arguments
Note that if a step function allows arguments, each and every argument must have a default value. (as in birth=[3].) This is so the step function could always be run with just a state, without requiring the user to figure out which arguments the simpack is expecting.
But we didn’t call step_generator directly with these arguments; we called garlicsim.simulate(). When called with additional arguments, garlicsim.simulate() simply passes them on to the step function.
We can specify arguments to garlicsim.list_simulate() and garlicsim.iter_simulate() in the same way:
>>> garlicsim.list_simulate(state, 20, birth=[0], survival=[1, 2])[-1]
#### ## ########## ######## # ########
## ##### ## ######## # ####
####### ##### ############# # ####
#### ##### # ############ # ######
#### # ##### ################ # #######
#### # #### ############### # ### #
#### # ## ############# # # ###### #
#### # ########## # ###### #
###### # # ############# # #### # #
#### # # ############# ##### # #### #
# #### ###################### #### ##
## ########## ######################
#### ########## # ############### ######
#### #### # # # ########### ######
########### ### ## # # ###### ##
########## ###### # ### ###### ## #
#### ########### #### # #
## ## ############ # ####### # # ## #
## # # ### ## # ####### # # #
## # # #### ### ## ####### #
## # #### # ############ ##
# # ######## ## ############# ##### #
# # # ######### ##################### #
# # # ######################### ##########
############## ######## # ##########
>>>
>>> list(
... garlicsim.iter_simulate(state, 20, birth=[0], survival=[1, 2])
... )[-1]
#### ## ########## ######## # ########
## ##### ## ######## # ####
####### ##### ############# # ####
#### ##### # ############ # ######
#### # ##### ################ # #######
#### # #### ############### # ### #
#### # ## ############# # # ###### #
#### # ########## # ###### #
###### # # ############# # #### # #
#### # # ############# ##### # #### #
# #### ###################### #### ##
## ########## ######################
#### ########## # ############### ######
#### #### # # # ########### ######
########### ### ## # # ###### ##
########## ###### # ### ###### ## #
#### ########### #### # #
## ## ############ # ####### # # ## #
## # # ### ## # ####### # # #
## # # #### ### ## ####### #
## # #### # ############ ##
# # ######## ## ############# ##### #
# # # ######### ##################### #
# # # ######################### ##########
############## ######## # ##########
In the case of synchronous simulation (i.e. simulate, list_simulate and iter_simulate,) passing arguments was so straightforward that we didn’t need to think about step profiles at all. But when dealing with asynchronous simulation, i.e. the garlicsim.Project class, step profiles are a powerful way to keep track of exactly which states were crunched using which step function and which arguments.
That’s right, we can use step profiles to use different step functions in the same Project.
Let’s start a life project and run it for 20 iterations without specifying any arguments:
>>> project = garlicsim.Project(life)
>>> root = project.root_this_state(state)
>>> project.begin_crunching(root, 20)
Job(node=<garlicsim.data_structures.Node with clock 0, root, leaf, touched,
blockless, at 0xf88f70>, crunching_profile=CrunchingProfile(clock_target=20,
step_profile=life.State.step_generator(<state>)))
>>> project.sync_crunchers()
<0 nodes were added to the tree>
>>> # Give it a few seconds to crunch before calling .sync_crunchers again:
>>> project.sync_crunchers()
<20 nodes were added to the tree>
>>> (path,) = project.tree.all_possible_paths()
>>> path[-1]
<garlicsim.data_structures.Node with clock 20, leaf, untouched, blockful,
crunched with life.State.step_generator(<state>), at 0xfdf370>
>>> path[-1].state
## # # # ### #
# # # ## ##
### # #
### ###
## #
# ##
# ## ## # ##
## ## ##
# ###
# ### # ####
## # # ## # # # #
## ## ## # # ### # # # # #
##### # # # # # # # ##
## # ## # #
### ## ## ### # #
#### ## # # #
# # # ## ## # # #
# # ### ## # #
# # # # # ### ## ###
# # # ## # # # # # ###
# # ## ## # ## # #
# ### # ## # #### #
# # ## ## # # #
# # # ### # ###
### # # # # ###
This is the same natural life state we got before.
Now, let’s bring our “alternate Life” arguments into this project:
>>> project.begin_crunching(root, 20, birth=[0], survival=[1, 2])
Job(node=<garlicsim.data_structures.Node with clock 0, root, touched,
blockless, at 0xf88f70>, crunching_profile=CrunchingProfile(
clock_target=20, step_profile=life.State.step_generator(<state>, [0],
[1, 2])))
See what we did there? We fed the arguments into Project.begin_crunching() the same way we did it for garlicsim.simulate(). Let’s crunch the simulation and see the result
>>> project.sync_crunchers()
<0 nodes were added to the tree>
>>> # Give it a few seconds to crunch before calling .sync_crunchers again:
>>> project.sync_crunchers()
<20 nodes were added to the tree>
>>> old_path, new_path = project.tree.all_possible_paths()
>>> assert old_path == path # This is the path we had before
>>> new_path[-1].state
#### ## ########## ######## # ########
## ##### ## ######## # ####
####### ##### ############# # ####
#### ##### # ############ # ######
#### # ##### ################ # #######
#### # #### ############### # ### #
#### # ## ############# # # ###### #
#### # ########## # ###### #
###### # # ############# # #### # #
#### # # ############# ##### # #### #
# #### ###################### #### ##
## ########## ######################
#### ########## # ############### ######
#### #### # # # ########### ######
########### ### ## # # ###### ##
########## ###### # ### ###### ## #
#### ########### #### # #
## ## ############ # ####### # # ## #
## # # ### ## # ####### # # #
## # # #### ### ## ####### #
## # #### # ############ ##
# # ######## ## ############# ##### #
# # # ######### ##################### #
# # # ######################### ##########
############## ######## # ##########
This is the same “alternate Life” state we saw before when using garlicsim.simulate().
Let’s observe the node that holds the above state:
>>> new_path[-1]
<garlicsim.data_structures.Node with clock 20, leaf, untouched, blockful,
crunched with life.State.step_generator(<state>, [0], [1, 2]), at 0xfed730>
We get a bunch of useful info about the node: Among these things, we get the step function and arguments that were used to crunch that node: crunched with life.State.step_generator(<state>, [0], [1, 2]).
Let’s look at the step profile on the node directly:
>>> new_path[-1].step_profile
StepProfile(<unbound method State.step_generator>, [0], [1, 2])
This is the time to ask:
A step profile contains all there is to know about how a state was crunched. That includes three things:
- step_profile.step_function: The step function that was used.
- step_profile.args: A tuple of positional arguments.
- step_profile.kwargs: A dict of keyword arguments.
When you know all three of the above, you know exactly how the state was created.
Step profiles are of the garlicsim.misc.StepProfile, which is defined in the garlicsim.misc.step_profile module. Explore its source code to learn more about how it works.
We accessed our step profile before from the node; every Node has a .step_profile attribute which contains the step profile with which it was crunched. Note though that the nodes that you create with functions like create_root and create_messy_root will have a .step_profile attribute equal to None; the reasoning is that since they were created “artificially” and not by crunching, they should not have an actual step profile.
Edited nodes will have the same .step_profile attribute as the template node that they were cloned from.
Blocks have a .step_profile attribute too; whichever step profile is there is the step profile that was used for crunching each of the nodes in the Block. Indeed, one of the conditions regarding blocks is that all the member nodes have been crunched using the same step profile and share the same .step_profile attribute. Nodes that don’t share the same step profile will not be grouped together in a Block in the first place.
Let’s check out the step profiles in the Project we made before. Remember, old_path is the path in which we used natural Life constants, and new_path is the path in which we used our “alternate Life” constants:
>>> old_path[0] # That's the root node containing the initial random state:
<garlicsim.data_structures.Node with clock 0, root, touched, blockless,
at 0xf88f70>
>>> assert old_path[0].step_profile is None # Since it's our root.
>>>
>>> old_path[-1] # That's the naturally-crunched node:
<garlicsim.data_structures.Node with clock 20, leaf, untouched, blockful,
crunched with life.State.step_generator(<state>), at 0xfdf370>\
>>>
>>> old_path[-1].step_profile # This is its step profile:
StepProfile(<unbound method State.step_generator>)
>>>
>>> old_path[-1].block # And its block:
<garlicsim.data_structures.Block of length 20, crunched with
life.State.step_generator(<state>) at 0xfd0c50>
>>> # We can see that the block knows it has been crunched with an
>>> # argument-less step profile:
>>> old_path[-1].block.step_profile
StepProfile(<unbound method State.step_generator>)
>>>
>>>
>>> new_path[-1].block # This is our "alternate Life" block:
<garlicsim.data_structures.Block of length 20, crunched with
life.State.step_generator(<state>, [0], [1, 2]) at 0xfed2b0>
>>> # This block knows it has been crunched with our "alternate Life"
>>> # step profile:
>>> new_path[-1].block.step_profile
StepProfile(<unbound method State.step_generator>, [0], [1, 2])
What we did about is combine two different step profiles in the same Tree. As a final exercise, we’ll combine two different step profiles in the same time-line:
>>> node = new_path[-1] # This is the node with the "alternate Life" state.
>>> project.begin_crunching(node, 1, project.build_step_profile())
Job(node=<garlicsim.data_structures.Node with clock 20, leaf, untouched,
blockful, crunched with life.State.step_generator(<state>, [0], [1, 2]),
at 0xfed730>, crunching_profile=CrunchingProfile(clock_target=21,
step_profile=life.State.step_generator(<state>)))
(What the Project.build_step_profile() method above did is create an argument-less step profile.)
>>> project.sync_crunchers()
<0 nodes were added to the tree>
>>> project.sync_crunchers()
<1 nodes were added to the tree>
>>> new_path[-1].state
#
# # #
# # #
# ####
# # # #
#
#
#
#
#
# #
# # #
# # #
#
# # # # #
## # # #
## # # #
# # ## #
# # #
# # ##
# # #
# # # # #
# #
# #
#
In one step almost all the live cells became dead immediately under the standard Life rules.
Now our path has a mixture of two different step profiles:
>>> list(new_path.iterate_blockwise())
[
<garlicsim.data_structures.Node with clock 0, root, touched, blockless,
at 0xf88f70>,
<garlicsim.data_structures.Block of length 20, crunched with
life.State.step_generator(<state>, [0], [1, 2]) at 0xfed2b0>,
<garlicsim.data_structures.Block of length 1, crunched with
life.State.step_generator(<state>) at 0xfede70>
]
These were a few basic manipulations of step profiles. For more info about step profiles, you can check out the documented source code.