Core Concepts

This guide explains the fundamental concepts behind StateTracker’s design.

States and States

A State is an object that can take on a finite number of discrete states, numbered from 0 to num_states - 1. The simplest state is a “leaf” state created directly with a specified number of states:

[1]:
from statetracker import Manager, State

with Manager():
    A = State(num_values=5, name="A")
    print(list(A))
[0, 1, 2, 3, 4]

States can be iterated to cycle through all their states, and their current state can be read or set via the state property.

The Manager Context

All states must be created within a Manager context. The Manager tracks all states and their relationships:

[2]:
with Manager() as mgr:
    A = State(num_values=3, name="A")
    B = State(num_values=2, name="B")

    # Manager tracks all states
    print(mgr.get_all_names())
['A', 'B']

Creating states outside a Manager context raises a RuntimeError.

State Composition

The power of StateTracker comes from composing states using operations. When you combine states, you create a new “derived” state that depends on its “parent” states:

[3]:
from statetracker import product

with Manager():
    A = State(num_values=2, name="A")
    B = State(num_values=3, name="B")

    # C is derived from A and B via product
    C = product([A, B], name="C")  # 6 states (2 × 3)

    print(f"C has {C.num_values} states")
C has 6 states

This creates a directed acyclic graph (DAG) of state dependencies. You can visualize this structure using print_dag():

[4]:
with Manager():
    A = State(num_values=2, name="A")
    B = State(num_values=3, name="B")
    C = product([A, B], name="C")

    C.print_dag()
C (counter, io=0, n=6)
└── [op=Product]
    ├── A (counter, io=0, n=2)
    └── B (counter, io=0, n=3)

Unidirectional State Propagation

StateTracker uses unidirectional state propagation: when you set the state of a derived state, the states of all its parent states are automatically computed and updated.

[5]:
with Manager():
    A = State(num_values=2, name="A")
    B = State(num_values=3, name="B")
    C = product([A, B])

    C.value = 5  # Set the derived state's state

    # Parent states are automatically computed
    print(f"A.value = {A.value}")  # 1
    print(f"B.value = {B.value}")  # 2
A.state = 1
B.state = 2

This is the key insight: you iterate over the derived state, and the parent states automatically track along.

Active vs Inactive States

A state’s state can be either:

  • Active: An integer from 0 to num_states - 1

  • Inactive: None

Inactive states arise with operations like stack where only one parent is “active” at a time:

[6]:
from statetracker import stack

with Manager():
    A = State(num_values=2, name="A")
    B = State(num_values=3, name="B")
    C = stack([A, B])  # 5 states total

    for state in C:
        print(f"C={state}, A={A.value}, B={B.value}")
C=0, A=0, B=None
C=1, A=1, B=None
C=2, A=None, B=0
C=3, A=None, B=1
C=4, A=None, B=2

Use is_active to check if a state is active. You can also use print_states() to conveniently display all states at once, or print_states(include_inactive=False) to show only active states.

Conflict Detection

When a state appears in multiple branches of the DAG, StateTracker detects conflicting state assignments. This happens when two different paths would assign different values to the same parent state.

[7]:
with Manager():
    A = State(num_values=3, name="A")
    B = A[0:2]  # B=0 → A=0, B=1 → A=1
    C = A[1:3]  # C=0 → A=1, C=1 → A=2

    # Using product tries to activate BOTH B and C simultaneously
    D = product([B, C])

    # D state 0 means B=0 (A=0) and C=0 (A=1)
    # But A can't be both 0 and 1 at the same time!
    try:
        for state in D:
            pass
    except Exception as e:
        print(f"Error: {type(e).__name__}")
Error: ConflictingStateAssignmentError

Note that stack does NOT cause conflicts because it only activates one parent at a time. The conflict arises when an operation like product tries to activate multiple states that share a common ancestor with incompatible state requirements.

Iteration Order and ordered_product()

Sometimes you want certain states to have priority over others when they appear together in Cartesian products. The ordered_product() function reads an iter_order property from each state to determine ordering. States with lower iter_order values iterate “faster” (change more frequently in the inner loop).

[8]:
from statetracker import ordered_product

with Manager():
    A = State(num_values=2, name="A", iter_order=0)  # Fast
    B = State(num_values=2, name="B", iter_order=1)  # Slow

    C = ordered_product([A, B])

    for _ in C:
        print(f"A={A.value}, B={B.value}")
A=0, B=0
A=1, B=0
A=0, B=1
A=1, B=1

The default iter_order is 0 for leaf states. Derived states inherit the minimum iter_order of their parents.

Note that the regular product() function does not use iter_order—it preserves the exact order of states you pass to it.

State Identity

Each state has a unique id assigned by the Manager. This is used for:

  • Deduplication in ordered_product()

  • Tie-breaking when states have the same iter_order

States are compared by identity (is), not value, so two states with the same num_states are still distinct objects.

Copying States

States support two types of copying:

  • copy(): Creates a new state with the same parents (shallow copy)

  • deepcopy(): Recursively creates a new state with copied ancestors

[9]:
with Manager():
    A = State(num_values=3, name="A")
    B = A[1:3]

    # Shallow copy: C shares parent A with B
    C = B.copy(name="C")

    # Deep copy: D has its own copy of the parent
    D = B.deepcopy(name="D")

    print("B's DAG:")
    B.print_dag()
    print("\nC's DAG (shallow copy - shares A):")
    C.print_dag()
    print("\nD's DAG (deep copy - has own parent):")
    D.print_dag()
B's DAG:
Counter[1] (counter, io=0, n=2)
└── [op=Slice]
    └── A (counter, io=0, n=3)

C's DAG (shallow copy - shares A):
C (counter, io=0, n=2)
└── [op=Slice]
    └── A (counter, io=0, n=3)

D's DAG (deep copy - has own parent):
D (counter, io=0, n=2)
└── [op=Slice]
    └── Counter[3] (counter, io=0, n=3)