Skip to content

Stock-Flow Models

A stock-flow model is the right choice when you want to reason about resources that accumulate and drain over time — queues, pools, budgets, rate limits, memory, tokens. The core question is: can the values of these resources reach a bad state?

Stock-flow models live in a single .fspec file. There is no state machine — just stocks, flows, assertions, and a run block that specifies the sequence of operations to verify.

Use stock-flow models when:

  • You have numeric quantities that change over time
  • You want to find overflow, underflow, starvation, or exhaustion scenarios
  • You want to check that concurrent operations don't corrupt shared state

Worked Example: Token Bucket Rate Limiter

A token bucket rate limiter refills at a fixed rate and consumes one token per accepted request. We want to verify two properties:

  1. The bucket never goes negative (no over-consumption)
  2. The bucket never exceeds capacity (no over-refill)

Defining the Stocks

Stocks represent the quantities we want to track. Here the bucket has a tokens count and a fixed capacity.

spec tokenBucket;

def bucket = stock{
    tokens: 10,
    capacity: 10,
};

tokens: 10 sets the starting value. The Fault compiler treats all numbers as reals internally, so integers and floats are interchangeable.

Defining the Flows

Flows define operations that change stock values. The <- operator increments, -> decrements.

def limiter = flow{
    b: new bucket,

    refill: func{
        if b.tokens < b.capacity {
            b.tokens <- 1;
        }
    },

    consume: func{
        if b.tokens > 0 {
            b.tokens -> 1;
        }
    },
};

The guard conditions (if b.tokens > 0) constrain when each function can execute. Fault will still explore scenarios where the guard doesn't hold — the function just becomes a no-op in that path.

Writing the Assertions

assert bucket.tokens >= 0;
assert bucket.tokens <= bucket.capacity;

assert tells the solver to find a counterexample — a scenario where these properties are violated. If it finds one, the trace shows exactly which sequence of operations caused it.

The Run Block

The run block instantiates the flow and specifies the sequence of operations to verify. The | operator means the two functions can execute in either order — Fault explores both.

run init {
    rl = new limiter;
} {
    rl.refill | rl.consume;
    rl.refill | rl.consume;
    rl.refill | rl.consume;
    rl.refill | rl.consume;
    rl.refill | rl.consume;
};

Five rounds, each with a non-deterministic choice of whether to refill or consume first. Fault checks that both assertions hold across all possible orderings.

Complete Model

spec tokenBucket;

def bucket = stock{
    tokens: 10,
    capacity: 10,
};

def limiter = flow{
    b: new bucket,

    refill: func{
        if b.tokens < b.capacity {
            b.tokens <- 1;
        }
    },

    consume: func{
        if b.tokens > 0 {
            b.tokens -> 1;
        }
    },
};

assert bucket.tokens >= 0;
assert bucket.tokens <= bucket.capacity;

run init {
    rl = new limiter;
} {
    rl.refill | rl.consume;
    rl.refill | rl.consume;
    rl.refill | rl.consume;
    rl.refill | rl.consume;
    rl.refill | rl.consume;
};

Making It More Interesting

Unknown starting values

Replace fixed values with unknown() to let the solver pick starting conditions:

def bucket = stock{
    tokens,           // solver picks any starting value
    capacity: 10,
};

This will immediately surface the underflow case if the guard conditions are insufficient.

Testing concurrent producers and consumers

run init {
    rl = new limiter;
} {
    rl.consume | rl.consume;
    rl.refill;
    rl.consume | rl.consume;
    rl.refill;
};

Two simultaneous consumers each round — does the guard prevent the bucket from going negative even when two consumers race?


Key Concepts

  • Stocks — reservoirs of resources
  • Flows — operations that change stocks
  • Data Types — numeric, boolean, unknown, uncertain, and more
  • Invariants — assertions and temporal qualifiers