Building a Calculation Engine with Generators

orcaset creates financial models as code. Models are defined by a collection of formulas, similar to traditional models in a spreadsheet. However, unlike spreadsheets, formulas in orcaset are not constrained to an eager, fixed cell grid. Instead, values are only materialized when they are requested. This lazy approach separates the model logic from model execution, which enables observability and performance improvements among other benefits.

The core evaluation engine is built around effect handlers implemented in Python using generators. Yielding a dependency is an effect (a task asks for a value without computing it) and the handler is the scheduler that intercepts that demand, evaluates it, and resumes the parent task. This article walks through the core ideas behind that system.

Resolving Dependencies

A model is composed of many (dependency) -> value evaluation tasks where the output from one task feeds the input for the following task. orcaset discovers and resolves those tasks using generators.

Concretely, a Task is a zero-argument function that returns either:

  1. a value of type T, or

  2. a generator (Step[T]) that i) yields its dependencies as tasks, ii) receives their resolved values back via send, and iii) finally returns a value of type T.

Tasks are driven by a scheduler that invokes the task function, receives any dependency requests it yields, evaluates those dependencies, and sends their values back. Those yields are the effects. The scheduler’s control loop is the handler.

The snippet below builds a basic task type and scheduler loop.

from collections.abc import Callable, Generator
from typing import Any

type Task[T] = Callable[[], T | Step[T]]
type Step[T] = Generator[Task[Any], Any, T]


def scheduler[T](task: Task[T]) -> T:
    """Drive a task to completion, recursively resolving yielded dependencies."""
    result = task()
    if not isinstance(result, Generator):
        return result

    try:
        dep = next(result)
        # Continue resolving yielded dependencies until the task terminates
        while True:
            dep = result.send(scheduler(dep))
    except StopIteration as e:
        # Generator function return values are passed as the StopIteration value (`e`)
        return e.value
from collections.abc import Callable, Generator
from typing import Any

type Task[T] = Callable[[], T | Step[T]]
type Step[T] = Generator[Task[Any], Any, T]


def scheduler[T](task: Task[T]) -> T:
    """Drive a task to completion, recursively resolving yielded dependencies."""
    result = task()
    if not isinstance(result, Generator):
        return result

    try:
        dep = next(result)
        # Continue resolving yielded dependencies until the task terminates
        while True:
            dep = result.send(scheduler(dep))
    except StopIteration as e:
        # Generator function return values are passed as the StopIteration value (`e`)
        return e.value
from collections.abc import Callable, Generator
from typing import Any

type Task[T] = Callable[[], T | Step[T]]
type Step[T] = Generator[Task[Any], Any, T]


def scheduler[T](task: Task[T]) -> T:
    """Drive a task to completion, recursively resolving yielded dependencies."""
    result = task()
    if not isinstance(result, Generator):
        return result

    try:
        dep = next(result)
        # Continue resolving yielded dependencies until the task terminates
        while True:
            dep = result.send(scheduler(dep))
    except StopIteration as e:
        # Generator function return values are passed as the StopIteration value (`e`)
        return e.value

We can model a simple two-period revenue model using two functions. jan_revenue is a leaf that returns the initial value of 100. feb_revenue equals January revenue increased by 10%.

def jan_revenue() -> float:
    return 100.0


def feb_revenue() -> Step[float]:
    jan_rev = yield jan_revenue
    return jan_rev * 1.1


print(scheduler(feb_revenue))  # 110.0
def jan_revenue() -> float:
    return 100.0


def feb_revenue() -> Step[float]:
    jan_rev = yield jan_revenue
    return jan_rev * 1.1


print(scheduler(feb_revenue))  # 110.0
def jan_revenue() -> float:
    return 100.0


def feb_revenue() -> Step[float]:
    jan_rev = yield jan_revenue
    return jan_rev * 1.1


print(scheduler(feb_revenue))  # 110.0

February revenue isn’t known until the scheduler materializes it. The execution flow looks like this:

  1. Evaluate feb_revenue, which immediately hits the dependency on jan_revenue

  2. Pause feb_revenue and hand control back to the scheduler by yielding the jan_revenue task

  3. The scheduler calls the jan_revenue task, which returns 100.0

  4. The scheduler sends 100.0 back to feb_revenue

  5. feb_revenue resumes execution and returns 100.0 * 1.1

Keyed Dependencies

The prior revenue model works, but it is awkward. Each monthly period is a separate function. Ideally we’d define the entire revenue line as one function parameterized by period. Conceptually something like:

def revenue(period: int):
    """Revenue for periods i >= 0"""
    if period == 0:
        return 100
    else:
        return revenue(period - 1) * 1.1
def revenue(period: int):
    """Revenue for periods i >= 0"""
    if period == 0:
        return 100
    else:
        return revenue(period - 1) * 1.1
def revenue(period: int):
    """Revenue for periods i >= 0"""
    if period == 0:
        return 100
    else:
        return revenue(period - 1) * 1.1

This sketch is ordinary Python recursion, not yet wired through the scheduler. In addition to being more concise, this form clearly highlights value-level dependencies: revenue for period i depends on revenue at period i - 1.

The current definition of Task only takes zero-argument callables as dependencies. There’s no way to pass the period argument through the scheduler. In order to pass it through, we need a new task definition that pairs a single-argument function with an argument value.

type KeyedTask[K, T] = tuple[Callable[[K], Step[T] | T], K]
type Step[T] = Generator[Task[Any] | KeyedTask[Any, Any], Any, T]
type KeyedTask[K, T] = tuple[Callable[[K], Step[T] | T], K]
type Step[T] = Generator[Task[Any] | KeyedTask[Any, Any], Any, T]
type KeyedTask[K, T] = tuple[Callable[[K], Step[T] | T], K]
type Step[T] = Generator[Task[Any] | KeyedTask[Any, Any], Any, T]

The scheduler also needs to accept keyed tasks and pass the key as the function argument.

def scheduler[K, T](task: Task[T] | KeyedTask[K, T]) -> T:
    """Drive a task to completion, recursively resolving yielded dependencies."""
    # Create the task, passing the key argument if applicable
    if isinstance(task, tuple):
        fn, key = task
        result = fn(key)
    else:
        result = task()

    # No change to the rest of the scheduler code
    if not isinstance(result, Generator):
        return result

    try:
        dep = next(result)
        while True:
            dep = result.send(scheduler(dep))
    except StopIteration as e:
        return e.value
def scheduler[K, T](task: Task[T] | KeyedTask[K, T]) -> T:
    """Drive a task to completion, recursively resolving yielded dependencies."""
    # Create the task, passing the key argument if applicable
    if isinstance(task, tuple):
        fn, key = task
        result = fn(key)
    else:
        result = task()

    # No change to the rest of the scheduler code
    if not isinstance(result, Generator):
        return result

    try:
        dep = next(result)
        while True:
            dep = result.send(scheduler(dep))
    except StopIteration as e:
        return e.value
def scheduler[K, T](task: Task[T] | KeyedTask[K, T]) -> T:
    """Drive a task to completion, recursively resolving yielded dependencies."""
    # Create the task, passing the key argument if applicable
    if isinstance(task, tuple):
        fn, key = task
        result = fn(key)
    else:
        result = task()

    # No change to the rest of the scheduler code
    if not isinstance(result, Generator):
        return result

    try:
        dep = next(result)
        while True:
            dep = result.send(scheduler(dep))
    except StopIteration as e:
        return e.value

Now we can parameterize tasks by period and define the entire revenue line in a single function.

def revenue(period: int):
    if period == 0:
        return 100.0
    else:
        prior_revenue = yield (revenue, period - 1)
        return prior_revenue * 1.1


for i in range(3):
    print(f"Revenue for period {i}: {scheduler((revenue, i))}")

# Revenue for period 0: 100.0
# Revenue for period 1: 110.0
# Revenue for period 2: 121.0
def revenue(period: int):
    if period == 0:
        return 100.0
    else:
        prior_revenue = yield (revenue, period - 1)
        return prior_revenue * 1.1


for i in range(3):
    print(f"Revenue for period {i}: {scheduler((revenue, i))}")

# Revenue for period 0: 100.0
# Revenue for period 1: 110.0
# Revenue for period 2: 121.0
def revenue(period: int):
    if period == 0:
        return 100.0
    else:
        prior_revenue = yield (revenue, period - 1)
        return prior_revenue * 1.1


for i in range(3):
    print(f"Revenue for period {i}: {scheduler((revenue, i))}")

# Revenue for period 0: 100.0
# Revenue for period 1: 110.0
# Revenue for period 2: 121.0

In orcaset, users don’t yield (task, key) pairs directly. They go through thin wrappers, mainly just for typing purposes in the full library. Conceptually they are just:

def get[T](task: Task[T]) -> Step[T]:
    value = yield task
    return value


def get_at[K: Hashable, T](task: Callable[[K], Step[T] | T], key: K) -> Step[T]:
    value = yield (task, key)
    return value
def get[T](task: Task[T]) -> Step[T]:
    value = yield task
    return value


def get_at[K: Hashable, T](task: Callable[[K], Step[T] | T], key: K) -> Step[T]:
    value = yield (task, key)
    return value
def get[T](task: Task[T]) -> Step[T]:
    value = yield task
    return value


def get_at[K: Hashable, T](task: Callable[[K], Step[T] | T], key: K) -> Step[T]:
    value = yield (task, key)
    return value

So the demand in revenue would be written prior_revenue = yield from get_at(revenue, period - 1). The rest of this article keeps yielding tuples so the scheduler mechanics stay visible.

Tracking Dependencies

Effect handlers separate model logic (tasks that demand values) from model execution (the scheduler that satisfies those demands). We can extend the handler without changing task code. For example, to track dependencies as demands are resolved.

We can trace dependencies by adding the currently executing task to a stack and saving an edge from the current task to any new tasks that are discovered during execution. Since we need to hold state during a resolution run, we’ll wrap the scheduler function into a class Context with instance variables for the stack and the dependency edges.

class Context:
    """Evaluate keyed tasks while recording which cells demand which dependencies."""

    def __init__(self) -> None:
        self._stack: list[KeyedTask[Any, Any]] = []
        self.deps: dict[KeyedTask[Any, Any], set[KeyedTask[Any, Any]]] = {}

    def get_at[K, T](self, task: KeyedTask[K, T]) -> T:
        # If a task is already mid-evaluation, record an edge from it to the requested task
        if self._stack:
            self.deps.setdefault(self._stack[-1], set()).add(task)

        fn, key = task

        # Push the requested task, evaluate it, then pop
        self._stack.append(task)
        try:
            result = fn(key)
            if not isinstance(result, Generator):
                return result
            try:
                dep = next(result)
                while True:
                    assert isinstance(dep, tuple)
                    dep = result.send(self.get_at(dep))
            except StopIteration as e:
                return e.value
        finally:
            self._stack.pop()
class Context:
    """Evaluate keyed tasks while recording which cells demand which dependencies."""

    def __init__(self) -> None:
        self._stack: list[KeyedTask[Any, Any]] = []
        self.deps: dict[KeyedTask[Any, Any], set[KeyedTask[Any, Any]]] = {}

    def get_at[K, T](self, task: KeyedTask[K, T]) -> T:
        # If a task is already mid-evaluation, record an edge from it to the requested task
        if self._stack:
            self.deps.setdefault(self._stack[-1], set()).add(task)

        fn, key = task

        # Push the requested task, evaluate it, then pop
        self._stack.append(task)
        try:
            result = fn(key)
            if not isinstance(result, Generator):
                return result
            try:
                dep = next(result)
                while True:
                    assert isinstance(dep, tuple)
                    dep = result.send(self.get_at(dep))
            except StopIteration as e:
                return e.value
        finally:
            self._stack.pop()
class Context:
    """Evaluate keyed tasks while recording which cells demand which dependencies."""

    def __init__(self) -> None:
        self._stack: list[KeyedTask[Any, Any]] = []
        self.deps: dict[KeyedTask[Any, Any], set[KeyedTask[Any, Any]]] = {}

    def get_at[K, T](self, task: KeyedTask[K, T]) -> T:
        # If a task is already mid-evaluation, record an edge from it to the requested task
        if self._stack:
            self.deps.setdefault(self._stack[-1], set()).add(task)

        fn, key = task

        # Push the requested task, evaluate it, then pop
        self._stack.append(task)
        try:
            result = fn(key)
            if not isinstance(result, Generator):
                return result
            try:
                dep = next(result)
                while True:
                    assert isinstance(dep, tuple)
                    dep = result.send(self.get_at(dep))
            except StopIteration as e:
                return e.value
        finally:
            self._stack.pop()

A Context records dependency edges for all tasks it evaluates. The example below prints dependencies for the first three revenue periods. The edge labels are not pretty, but they clearly show the dependency chain from revenue at period 2 to revenue at period 1 to revenue at period 0.

ctx = Context()

# Print first three values
for i in range(3):
    print(f"Revenue for period {i}: {ctx.get_at((revenue, i))}")

# Print edges
for cell, children in ctx.deps.items():
    print(f"{cell} -> {children}")

# Revenue for period 0: 100.0
# Revenue for period 1: 110.0
# Revenue for period 2: 121.0

# (<function revenue at 0x1037f5850>, 1) -> {(<function revenue at 0x1037f5850>, 0)}
# (<function revenue at 0x1037f5850>, 2) -> {(<function revenue at 0x1037f5850>, 1)}
ctx = Context()

# Print first three values
for i in range(3):
    print(f"Revenue for period {i}: {ctx.get_at((revenue, i))}")

# Print edges
for cell, children in ctx.deps.items():
    print(f"{cell} -> {children}")

# Revenue for period 0: 100.0
# Revenue for period 1: 110.0
# Revenue for period 2: 121.0

# (<function revenue at 0x1037f5850>, 1) -> {(<function revenue at 0x1037f5850>, 0)}
# (<function revenue at 0x1037f5850>, 2) -> {(<function revenue at 0x1037f5850>, 1)}
ctx = Context()

# Print first three values
for i in range(3):
    print(f"Revenue for period {i}: {ctx.get_at((revenue, i))}")

# Print edges
for cell, children in ctx.deps.items():
    print(f"{cell} -> {children}")

# Revenue for period 0: 100.0
# Revenue for period 1: 110.0
# Revenue for period 2: 121.0

# (<function revenue at 0x1037f5850>, 1) -> {(<function revenue at 0x1037f5850>, 0)}
# (<function revenue at 0x1037f5850>, 2) -> {(<function revenue at 0x1037f5850>, 1)}

The orcaset library uses the same principles to track dependencies across calculations, with better task identities, human-readable labels, and a more ergonomic API.

Effect Handlers in Orcaset

The main purpose of effect handlers in orcaset is to separate model logic from execution. It gives users granular control over the flow of programs to perform side effects that make models faster, open, and inspectable. Specifically, it allows orcaset users to:

  • Track dependencies: Dependency graphs for debugging model logic and verifying results

  • Memoize values: Financial models are highly recursive; the work explodes without memoization

  • Mock values: Useful for testing or scenario analysis

  • Step through calculations: Inspect model drivers and execution flow

  • Replay results: Re-emit prior resolutions for auditability and reproducibility

While the ideas here match orcaset‘s effect handler system, the library implements the scheduler differently for scalability. For example, the recursive handler in this article (dep = result.send(scheduler(dep))) adds a Python stack frame per task. Moderately deep models would hit CPython’s default 1,000-frame limit and raise a RecursionError. orcaset uses an iterative scheduler so dependency chains can be arbitrarily deep.

This post might vaguely remind you of a build system. There are many (dependency) -> value tasks that need to be discovered and resolved in order. The design of orcaset is partly inspired by Build Systems à la Carte: Theory and Practice (Andrey Mokhov, Neil Mitchell and Simon Peyton Jones). Calculation tasks are ordered using a suspending scheduler via generators (rather than a topological sort or restarting scheduler). Values in orcaset are currently static so the “rebuilder” dimension from the paper is not applicable, but future library development may add reactivity which would fit the library into the authors’ scheduler-rebuilder matrix nicely.

Star the repository at https://github.com/orcaset/orcaset-py to follow future development or examine the full codebase!