Lag System Design & Implementation
Overview
The lag system provides access to historical state values in dynlib models using the notation:
- lag_<name>() - Access state <name> from one step ago
- lag_<name>(k) - Access state <name> from k steps ago
Key Features: - On-demand activation (only lagged states consume memory) - O(1) circular buffer access (Numba-compatible) - Counted after successful committed steps (immune to buffer growth, early breaks, resume) - Works with both ODE and map models - Dedicated runtime workspace (stepper ABI extended with runtime_ws parameter)
DSL Syntax
Supported Usage
[model]
type = "map"
[states]
x = 0.1
[params]
r = 3.5
alpha = 0.3
[equations.rhs]
# Mix current and lagged states
x = "r * (alpha * x + (1 - alpha) * lag_x(1)) * (1 - x)"
# Use zero-arg shorthand for one step back
x = "r * (alpha * x + (1 - alpha) * lag_x()) * (1 - x)"
Lag Depths
[aux]
# Use multiple lag depths - max is automatically detected
delayed_diff = "x - lag_x(5)"
[equations.rhs]
x = "v + 0.1 * lag_x(2)" # Max lag for x = 5 (from aux)
v = "-x - lag_v(3)" # Max lag for v = 3
Restrictions
Only for state variables:
[states]
x = 0.1
[params]
a = 2.0
[equations.rhs]
x = "lag_x(1)" # Valid - x is a state
x = "lag_a(1)" # Error - a is a parameter, not a state
Lag argument must be integer literal:
x = "lag_x(2)" # Valid
x = "lag_x(k)" # Error - k is not a literal
x = "lag_x(2 + 1)" # Error - expression not allowed
Sanity limit:
Lagging Auxiliary Variables
Auxiliary variables CANNOT be lagged directly. Instead, use lagged states in expressions:
Not Supported:
[aux]
energy = "0.5 * v^2 + 0.5 * k * x^2"
[equations.rhs]
v = "-x - 0.1 * lag_energy(1)" # ERROR: energy is aux, not state
Correct Approach:
[aux]
energy = "0.5 * v^2 + 0.5 * k * x^2" # Current energy (optional)
[equations.rhs]
# Compute lagged energy from lagged states
v = "-x - 0.1 * (0.5 * lag_v(1)^2 + 0.5 * k * lag_x(1)^2)"
Rationale: Auxiliaries are ephemeral derived quantities. Lagging energy is mathematically equivalent to computing energy from lagged states.
Alternative: Promote Aux to State
If a derived quantity is frequently lagged:
[states]
x = 0.1
v = 0.0
energy = 0.005 # Promoted from aux
[params]
k = 2.0
[equations.rhs]
x = "v"
v = "-k * x - 0.1 * lag_energy(1)" # Clean access
energy = "0.5 * v^2 + 0.5 * k * x^2" # Track as ODE
Trade-off: Increases system dimension by 1.
Storage Architecture
RuntimeWorkspace Structure
Lag buffers are stored in a dedicated RuntimeWorkspace NamedTuple:
Components:
- lag_ring: Contiguous array storing all circular buffers (dtype matches model)
- lag_head: Array of current head indices for each lagged state (int32)
- lag_info: Metadata array with shape (n_lagged_states, 3) containing (state_idx, depth, offset)
Circular Buffer Layout
Each lagged state gets a contiguous segment in lag_ring:
lag_ring layout (contiguous):
┌──────────────────┬──────────────────┬─────────────┐
│ lag buffer for x │ lag buffer for y │ (unused) │
│ (depth 5) │ (depth 3) │ │
└──────────────────┴──────────────────┴─────────────┘
offset=0 offset=5 end
Allocation:
- Each lagged state with depth k gets k consecutive elements in lag_ring
- Total lag_ring size = sum of all lag depths
- lag_head has one entry per lagged state
- lag_info[j] = (state_idx, depth, offset) for lagged state j
Circular Buffer Mechanics
Access Pattern
For lag_x(k) where state x has:
- depth = 5 (max lag)
- offset = 0 (starts at lag_ring[0])
- head_index = 0 (head at lag_head[0])
Lowered expression:
runtime_ws.lag_ring[offset + ((runtime_ws.lag_head[head_index] - k) % depth)]
# runtime_ws.lag_ring[0 + ((runtime_ws.lag_head[0] - k) % 5)]
Initialization (at t=t0)
# Fill with IC for each lagged state
for j, (state_idx, depth, offset) in enumerate(lag_info):
value = y_curr[state_idx]
runtime_ws.lag_ring[offset : offset + depth] = value
runtime_ws.lag_head[j] = depth - 1 # head at last position
Why depth-1? So that after first step commit, head wraps to 0.
Update After Committed Step
CRITICAL: Updates happen ONLY after successful step commits, not after rejected steps or buffer growths.
# In runner.py, after commit:
for j in range(n_lagged_states):
state_idx, depth, offset = lag_info[j]
head = int(lag_head[j]) + 1
if head >= depth:
head = 0
lag_head[j] = head
lag_ring[offset + head] = y_curr[state_idx]
Example trace for x with depth=3:
Step 0 (IC=0.1):
lag_ring = [0.1, 0.1, 0.1], head=2
Step 1 (y_curr=0.2):
head = (2+1) % 3 = 0
lag_ring[0] = 0.2 → lag_ring = [0.2, 0.1, 0.1], head=0
Step 2 (y_curr=0.3):
head = (0+1) % 3 = 1
lag_ring[1] = 0.3 → lag_ring = [0.2, 0.3, 0.1], head=1
Step 3 (y_curr=0.4):
head = (1+1) % 3 = 2
lag_ring[2] = 0.4 → lag_ring = [0.2, 0.3, 0.4], head=2
Access lag_x(1) at step 3:
lag_ring[0 + ((2 - 1) % 3)] = lag_ring[1] = 0.3 ✓ (step 2 value)
Access lag_x(2) at step 3:
lag_ring[0 + ((2 - 2) % 3)] = lag_ring[0] = 0.2 ✓ (step 1 value)
Safety & Correctness
Buffer Growth (GROW_REC, GROW_EVT)
Lag buffers are NOT reallocated during recording/event buffer growth:
- Runtime workspace sizes are determined by lag depths (model-specific, not trajectory-dependent)
- Wrapper doubles rec/ev buffers, but leaves runtime workspace unchanged
- Lag state preserved across re-entry
Early Breaks (STEPFAIL, NAN_DETECTED, USER_BREAK)
- Runner commits state before break
- Lag buffers contain values up to last successful commit
- Resume uses
workspace_seedto restore exact lag state
Resume & Snapshots
RuntimeWorkspacesupportssnapshot_workspace()andrestore_workspace()- Lag buffers automatically included in workspace snapshots
- No special handling needed
Correctness guarantee: Lags are counted after committed steps only.
Example: Logistic Map with Delay
[model]
type = "map"
name = "Delayed Logistic Map"
[states]
x = 0.1
[params]
r = 3.8
alpha = 0.7 # Mix of current and delayed feedback
[equations.rhs]
# Delay-coupled logistic map
x = "r * (alpha * x + (1 - alpha) * lag_x()) * (1 - x)"
[sim]
t0 = 0.0
dt = 1.0
stepper = "map"
Execution trace:
n=0: x=0.1, lag_x(1)=0.1 (IC)
n=1: x = 3.8*(0.7*0.1 + 0.3*0.1)*(1-0.1) = 0.342
lag_x(1)=0.1
n=2: x = 3.8*(0.7*0.342 + 0.3*0.1)*(1-0.342) = 0.627
lag_x(1)=0.342
n=3: x = 3.8*(0.7*0.627 + 0.3*0.342)*(1-0.627) = 0.788
lag_x(1)=0.627
...
Performance
Memory Overhead
Per lagged state with depth k:
- Storage: k * sizeof(dtype) bytes in RuntimeWorkspace.lag_ring
- Head indices: 1 int32 per lagged state in RuntimeWorkspace.lag_head
- Metadata: 3 int32 per lagged state in RuntimeWorkspace.lag_info
- Example: 3 states, depth 10, float64 → 3 * 10 * 8 = 240 bytes + overhead
Computational Cost
- Per step: O(n_lagged_states) writes to circular buffer
- Lag access: O(1) modulo + array index
- Numba optimizes
(x - k) % depthto bitwise AND if depth is power-of-2
References
- Runtime Workspace:
runtime/workspace.py - DSL Spec:
dsl/spec.py - Expression Lowering:
compiler/codegen/rewrite.py - Runner ABI:
runtime/runner_api.py - Stepper Base:
steppers/base.py