Circuits

Circuits are computational networks that transform streams. They are built from atomic operations (add, multiply, integrate, etc.) and combinators (composition, monoidal, trace, compose).

Circuit Syntax

Circuits use a functional composition style:

from gimle.asgard.circuit.circuit import Circuit

# Simple integration circuit
circuit = Circuit.from_string("register(x)")

# Fundamental theorem: integrate then differentiate
circuit = Circuit.from_string("composition(register(x), deregister(x))")

# Parallel operations
circuit = Circuit.from_string("monoidal(scalar(2.0), scalar(3.0))")

# Power series composition: exp(2t)
circuit = Circuit.from_string("compose(exp(8), scalar(2.0), t)")

print(circuit)
# Output: composition(register(x), deregister(x)) [1->1]

Combinators

Combinators define how circuits connect. Every circuit has an input degree and output degree — the number of wires in and out.

Composition (Sequential)

Syntax: composition(f, g) — Apply f, then apply g to the result.

Signature: If f : A -> B and g : B -> C, then composition(f, g) : A -> C

# Integrate then differentiate
circuit = Circuit.from_string("composition(register(x), deregister(x))")

# Chain three operations
circuit = Circuit.from_string("composition(composition(add, scalar(2.0)), register(t))")

The output degree of f must match the input degree of g. This is validated at parse time.

Monoidal (Parallel)

Syntax: monoidal(f, g) — Apply f and g independently to separate inputs.

Signature: If f : A -> B and g : C -> D, then monoidal(f, g) : A+C -> B+D

# Apply different scalars to two inputs
circuit = Circuit.from_string("monoidal(scalar(2.0), scalar(3.0))")

# One operation, one pass-through
circuit = Circuit.from_string("monoidal(register(x), id)")

Trace (Feedback)

Syntax: trace(f) — Create a feedback loop connecting the last output to the last input.

Signature: If f : A+X -> B+X, then trace(f) : A -> B

# Feedback loop for solving differential equations
circuit = Circuit.from_string(
    "trace(composition(composition(add, split), monoidal(id, register(x))))"
)

The trace operator is how differential equations become executable: the feedback carries the running solution, with register accumulating coefficients iteratively.

Compose (Power Series Composition)

Syntax: compose(f, g, dim) — Compute the power series composition $f(g(x))$.

Signature: f must be 0->1 (generator) or 1->1 (transformer), g must be 1->1. Result is always 1->1.

# exp(2t): compose exp series with 2t scaling
circuit = Circuit.from_string("compose(exp(8), scalar(2.0), t)")

# sin(3x): compose sin series with 3x scaling
circuit = Circuit.from_string("compose(sin(8), scalar(3.0), x)")

This is essential for nonlinear operations on power series — it computes the Taylor coefficients of the composed function.

Atomic Operations

Complete Reference

Generators (0 inputs):

Operation Signature Description
const(n) 0 -> 1 Constant stream
param(name) 0 -> 1 Runtime parameter (resolved from var_values)
sin(n) 0 -> 1 Sine Taylor coefficients (up to degree n)
cos(n) 0 -> 1 Cosine Taylor coefficients (up to degree n)
exp(n) 0 -> 1 Exponential Taylor coefficients (up to degree n)
black_box_source(name) 0 -> 1 Custom external data source

Unary (1 input, 1 output):

Operation Signature Description
id 1 -> 1 Identity (pass-through)
scalar(n) 1 -> 1 Multiply by constant
register(dim) 1 -> 1 Integration along dimension
deregister(dim) 1 -> 1 Differentiation along dimension
stochastic_register(dim) 1 -> 1 Stochastic integration (dW)
var(name) 1 -> 1 Variable reference
exp 1 -> 1 Exponential (coefficient transformer)
power(n) 1 -> 1 Raise to fixed exponent
abs 1 -> 1 Absolute value
log 1 -> 1 Natural logarithm
black_box(name) 1 -> 1 Custom external function

Binary (2 inputs):

Operation Signature Description
add 2 -> 1 Element-wise addition
multiplication 2 -> 1 Element-wise multiplication
division 2 -> 1 Element-wise division
power 2 -> 1 Binary power ($X^Y$)
convolution(dim) 2 -> 1 Convolution along dimension

Routing:

Operation Signature Description
split 1 -> 2 Duplicate input to two outputs
swap 2 -> 2 Swap two input wires
terminal 1 -> 0 Terminate a wire (discard)

Register (Integration)

Shifts coefficients right, mathematically equivalent to integration:

circuit = Circuit.from_string("register(x)")

# Behavior on Taylor coefficients:
# [a, b, c] -> [0, a, b]
# Represents: f(x) -> integral of f(x) dx

Deregister (Differentiation)

Shifts coefficients left, mathematically equivalent to differentiation:

circuit = Circuit.from_string("deregister(x)")

# Behavior on Taylor coefficients:
# [a, b, c] -> [b, c]
# Represents: f(x) -> df/dx

Stochastic Register

Like register, but integrates against Brownian motion (dW) instead of time (dt). Used in the diffusion term of SDEs:

circuit = Circuit.from_string("stochastic_register(t)")

When executed under the stochastic runtime, this produces Monte Carlo paths rather than deterministic coefficients.

Transcendental Generators

sin(n), cos(n), and exp(n) generate Taylor coefficients with factorial scaling up to degree n:

# exp coefficients: [1, 1, 1, 1, ...] (factorial-scaled)
circuit = Circuit.from_string("exp(8)")

# sin coefficients: [0, 1, 0, -1, 0, 1, ...]
circuit = Circuit.from_string("sin(8)")

# cos coefficients: [1, 0, -1, 0, 1, 0, ...]
circuit = Circuit.from_string("cos(8)")

Note: bare exp (without a degree argument) is a transformer (1->1) that applies the exponential coefficient-by-coefficient. The generator form exp(n) is 0->1.

Black-Box Operations

Wrap external functions as circuit primitives:

# A learned function (e.g., neural network)
circuit = Circuit.from_string("black_box(learned_model)")

# An external data source
circuit = Circuit.from_string("black_box_source(observed_data)")

Black-box operations are first-class citizens — they compose with all other operations and support backpropagation for end-to-end training.

Executing Circuits

Circuits transform streams of coefficients:

from gimle.asgard.circuit.circuit import Circuit
from gimle.asgard.runtime.stream import Stream, StreamState
import jax.numpy as jnp

# Create integration circuit
circuit = Circuit.from_string("register(x)")

# Input: f(x) = x (Taylor coefficients [0, 1])
input_stream = Stream(
    data=jnp.array([[0.0, 1.0]]),
    dim_labels=("x",),
    chunk_size=1
)

# Execute circuit
outputs, state = circuit.execute([input_stream], StreamState())

print(f"Input: {input_stream.data[0]}")   # [0, 1] = x
print(f"Output: {outputs[0].data[0]}")    # [0, 0, 1] = x^2/2

Type Checking

Circuits are validated at parse time. The output degree of f must match the input degree of g in composition(f, g):

# Valid: add (2->1) then scalar(2.0) (1->1)
circuit = Circuit.from_string("composition(add, scalar(2.0))")

# Valid: register(x) (1->1) then deregister(x) (1->1)
circuit = Circuit.from_string("composition(register(x), deregister(x))")

# Check degrees
print(f"Input degree: {circuit.input_degree}")
print(f"Output degree: {circuit.output_degree}")

Circuit Properties

Every parsed circuit exposes structural metadata:

circuit = Circuit.from_string(
    "trace(composition(composition(add, split), monoidal(id, register(x))))"
)

circuit.input_degree           # 1
circuit.output_degree          # 1
circuit.has_trace              # True
circuit.has_register           # True
circuit.has_stochastic_register  # False
circuit.dimension_identifiers  # frozenset({'x'})

These flags determine how the runtime executes the circuit — traced circuits with registers use the stream calculus evaluator for correct coefficient-by-coefficient semantics.

Next Steps