Quickstart

Your first simulation

go get github.com/umbralcalc/stochadex

A complete program: a random walk, five steps, every step recorded.

package main

import (
    "fmt"

    "github.com/umbralcalc/stochadex/pkg/continuous"
    "github.com/umbralcalc/stochadex/pkg/simulator"
)

func main() {
    gen := simulator.NewConfigGenerator()

    // One component: a Wiener process (random walk) starting at 0.
    gen.SetPartition(&simulator.PartitionConfig{
        Name:              "walk",
        Iteration:         &continuous.WienerProcessIteration{},
        Params:            simulator.NewParams(map[string][]float64{"variances": {1.0}}),
        InitStateValues:   []float64{0.0},
        StateHistoryDepth: 1,
        Seed:              42,
    })

    // Run for five steps, recording every step into storage.
    store := simulator.NewStateTimeStorage()
    gen.SetSimulation(&simulator.SimulationConfig{
        OutputCondition:      &simulator.EveryStepOutputCondition{},
        OutputFunction:       &simulator.StateTimeStorageOutputFunction{Store: store},
        TerminationCondition: &simulator.NumberOfStepsTerminationCondition{MaxNumberOfSteps: 5},
        TimestepFunction:     &simulator.ConstantTimestepFunction{Stepsize: 1.0},
    })
    simulator.NewPartitionCoordinator(gen.GenerateConfigs()).Run()

    fmt.Println("times:", store.GetTimes())
    fmt.Println("walk: ", store.GetValues("walk"))
}
go run .
times: [0 1 2 3 4 5]
walk:  [[0] [-0.27282789148858066] [-1.3375369499117022] [-2.548435603601376] [-0.6832544462398817] [-0.3886233811019282]]

That is a working stochastic simulation. Tweak MaxNumberOfSteps, variances, or Seed.

What you just built

How it works covers coupling, custom iterations, and worked examples (Itô’s lemma, Hawkes, embedded simulations, online inference).

Where the results go

The walk output is plain [][]float64, but the same run flows straight out:

See the Integrations table for the full set.

Running from a config file

No Go needed. Describe a whole run in one YAML file and run it with a prebuilt binary. A config that names no Go runs in-process: no codegen, no toolchain.

Install the CLI

# Prebuilt binary, picks your platform's asset from the latest release:
curl -L "https://github.com/umbralcalc/stochadex/releases/latest/download/stochadex-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" -o stochadex
chmod +x stochadex

# Or with Go, build from a checkout (the CLI is its own module):
git clone https://github.com/umbralcalc/stochadex && cd stochadex/cmd/stochadex && go build -o stochadex .

# Or as a container:
docker pull ghcr.io/umbralcalc/stochadex:latest

The image’s working directory is /work, so mount your project there and every path below works as written:

docker run --rm -v "$PWD:/work" ghcr.io/umbralcalc/stochadex:latest --config my_config.yaml

Which build?

Asset Contains Notes
stochadex-<os>-<arch> engine, Postgres, Arrow, S3 The default. Pure Go, runs anywhere with no system dependencies.
stochadex-accel-<os>-<arch> the above plus system BLAS and DuckDB output For BLAS-heavy workloads (see performance) or DuckDB output. macOS/Linux, amd64/arm64.

Swap stochadex- for stochadex-accel- in the download URL. Both run the same configs. The container carries the accelerated set already. Run --version to see what yours has.

Your first config

A 1-D random walk, recorded every step:

# walk.yaml
main:
  partitions:
  - name: walk
    iteration: {type: wiener_process}
    params: {variances: [1.0]}
    init_state_values: [0.0]
    state_history_depth: 1
    seed: 7
  simulation:
    termination_condition: {type: number_of_steps, max_steps: 5}
    timestep_function: {type: constant, stepsize: 1.0}
    init_time_value: 0.0
stochadex --config walk.yaml

With no outputs: declared, every step prints to stdout, one row per step, <time> <partition> [<state values>]:

0 walk [0]
1 walk [-0.10275106104846077]
2 walk [1.724114244166499]
3 walk [0.7336019413185719]
4 walk [0.01102228754125667]
5 walk [1.101502572408065]

To serve it over a websocket for live dashboards, add a run: block (see run modes) and publish the port for the container:

docker run --rm -p 2112:2112 -v "$PWD:/work" ghcr.io/umbralcalc/stochadex:latest \
  --config cfg/example_serve_config.yaml

The anatomy of a partition

A partition advances a vector state each step from its params and, optionally, other partitions’ states.

Field Meaning
params Named inputs; every value is a list of float64 (a scalar is [0.5]).
init_state_values The state vector at t=0. Its length is the state width.
state_history_depth How many past steps to retain (≥1).
seed Per-partition RNG seed.
iteration A library process named as data, or omit it and supply expressions.

The simulation block is all data too: termination_condition (number_of_steps / time_elapsed), timestep_function (constant / exponential_distribution).

Writing results: outputs:

Results leave a run through a top-level outputs: list of views. Each view has a name, an optional condition (default every_step), and a function:

outputs:
  - {name: log, function: {type: json_log, path: run.log}}
  - {name: sparse, condition: {type: every_n_steps, n: 10}, function: {type: stdout}}

Conditions are every_step, every_n_steps, only_given_partitions and nil. Functions are stdout, json_log, websocket, arrow, duckdb, postgres, s3 and nil. A config with no outputs: prints every step to stdout. (The older simulation.output_condition / output_function pair is deprecated shorthand for one view named output; a config that uses it prints a notice giving the exact outputs: line to replace it with.)

Columnar, streaming and database outputs

Beyond stdout and json_log, write columnar output directly:

outputs:
  - {name: columns, function: {type: arrow, path: run.arrow}}                     # Arrow IPC file
  - {name: table, function: {type: duckdb, path: run.duckdb, table: results}}   # DuckDB table

To stream a run live to another service (a dashboard, a recorder), push it to a websocket server. The run connects as a client when it starts, sends each output as a protobuf PartitionState frame, and closes the connection when it finishes:

outputs:
  - {name: push, function: {type: websocket, url: "ws://localhost:8080/ingest"}}

postgres takes local credentials, or driver/dsn through database/sql to reach any Postgres-wire database (TimescaleDB, CockroachDB, a managed instance):

outputs:
  - {name: db, function: {type: postgres, driver: pgx, dsn: "postgres://...", table: results}}

s3 is a transport, not a format: give it a format: and it reuses the normal sink, so anything writable locally is writable to object storage. Credentials come from the standard AWS chain, never the config file. Set endpoint: for any S3-compatible store (MinIO, R2, Ceph):

outputs:
  - {name: lake, function: {type: s3, bucket: my-bucket, key: runs/out.arrow, format: arrow}}

The same formats work as input sources to read a run back in: {arrow: {path: run.arrow}}, {postgres: {...}}, {s3: {bucket, key, format}}. Name a source the binary lacks and the error lists the ones it has.

arrow writes one IPC file (a time column plus a fixed-size list column per partition), read natively by Polars, pandas and DuckDB:

import pyarrow.ipc as ipc
table = ipc.open_file("run.arrow").read_all()

duckdb lands the same data in a DuckDB table (zero-copy). Both write once at the end, so they need a view whose condition emits every partition every step.

arrow, postgres, s3 are in every binary; the container adds duckdb. duckdb needs the accelerated binary. stochadex --version prints a features: line.

All or nothing. A run’s files, objects and tables appear only once the whole run has ended cleanly. A failed, crashed or killed run leaves nothing at their destinations, and any earlier run’s output there untouched, so a retry is always safe:

Output While the run is going Once it has ended cleanly
json_log, arrow, a stream input’s record: written to <path>.partial renamed to <path>, replacing the file in one step
s3 staged in a local temporary file uploaded: an object is replaced whole
duckdb held in memory ingested in one CREATE TABLE AS
stdout, websocket, {type: connection}, postgres streamed as the run goes (nothing held back: a failed run leaves what it sent)

An embedded run’s debug log is published with its outer run. A served connection’s outputs are published when its session ends, and the client leaving counts as a clean end. A killed run can leave a .partial file behind, which the next run replaces. Publishing costs one rename per file, once per run and never per step.

Driving a run from data: inputs:

A run can replay recorded or generated data. Declare it under inputs:, then replay one of an input’s partitions into a main: partition with {type: from_input}:

inputs:
  obs:  {source: {csv: {path: obs.csv, time_column: 0, state_columns: {flow: [1]}}}}
main:
  partitions:
  - name: flow                      # replays obs's "flow" (partition: renames)
    iteration: {type: from_input, input: obs}
    state_history_depth: 1          # init_state_values default to the input's first row
    seed: 0
  # ... partitions that read flow via params_from_upstream ...
  simulation:
    timestep_function:     {type: from_input, input: obs}       # the clock follows the input
    termination_condition: {type: input_exhausted, input: obs}  # and stops when it runs out

An input is any source: (csv, json_log, postgres, plus arrow and s3 in the distributed CLI), or a pre-pass simulation ({simulation: {steps, timestep, partitions: ...}}). A run’s json_log output can be the next run’s input, which is how separate configs chain.

A short series can be carried in the config itself, as an inline source. It needs a row per time, and an input with only times: can drive a clock:

inputs:
  sun:
    source:
      inline:
        times: [0.0, 1.0, 2.0, 3.0]
        partitions: {clear_sky: [[0.0], [5.0], [30.0], [120.0]]}

A from_storage iteration or timestep function with inline data: is shorthand for an inline input read by from_input, so inspect --io and provenance see that data as an input too.

A file an iteration reads, such as an ONNX model, is an input too. Declare it with file: and name it in the iteration field that takes its path:

inputs:
  brain: {file: {path: models/brain.onnx}}
main:
  partitions:
  - name: policy
    iteration: {type: onnx_inference, model_path: {input: brain}}
    # ...

Any iteration field written {input: NAME} is given that file’s path when the config loads. A plain model_path: models/brain.onnx is shorthand for a file input of that path. --check doesn’t read model files, and a run reads them when it starts.

Inputs are read when the run starts, never when the config is loaded: - a missing input exits as unavailable (75); - an input lacking the named partition exits as a data error (65); - an undeclared or unused input is a config error (78).

A macros: config reads inputs: too: its macros analyse every input’s partitions. When there are several inputs they must share one time axis, and a partition name may come from only one of them. (A top-level data: block, the older spelling of a single input named data, still works but is deprecated: it prints a notice naming its replacement.)

Setting params from an input, including a live stream

An input can set a partition’s params instead of its state. Declare the param with the value it has until the input sets it, and name where it comes from:

  - name: walk
    iteration: {type: wiener_process}
    params: {variances: [1.0]}
    params_from_input: {variances: {input: vol}}   # partition: defaults to the key's name

From a stored input, step k gets row k, as from_input does (row 0 is the initial state). This is the cheaper way to drive params from data: there is no extra partition, and nothing is copied or allocated per step.

An input can also be a live stream, read while the run runs:

inputs:
  feed:
    stream: {websocket: {url: "ws://localhost:9000/prices"}}   # connects as a client
    record: feed.log                                           # optional
main:
  partitions:
  - name: trader
    params: {price: [100.0]}                     # the price until the first message
    params_from_input: {price: {input: feed}}

Steering a run from the keyboard

A stream can be the keyboard. Each mapped key sets a stream partition’s value, which holds until the next mapped key. Unmapped keys are ignored, and Esc stops listening; the run carries on with the last value. Pace the run so there’s time to press anything:

inputs:
  keys:
    stream:
      keyboard: {partition: steer, keys: {a: [-1.0], d: [1.0], s: [0.0]}}
    record: keys.log                # what the keyboard gave each step, for replay
main:
  partitions:
  - name: steering
    iteration: {type: param_values}
    params: {param_values: [0.0]}
    params_from_input: {param_values: {input: keys, partition: steer}}
    init_state_values: [0.0]
    state_history_depth: 1
    seed: 0
  # ... partitions that read steering via params_from_upstream ...
run: {pace_ms: 200}                 # a step every 200ms

Replaying keys.log as {source: {json_log: {path: keys.log}}} repeats the run exactly. The keyboard is available in the stochadex CLI; from Go, import github.com/umbralcalc/stochadex/pkg/keyboard to register it.

Two-way serving: a client steers its own run

Under run: {mode: serve}, a stream can read the client the run is served to: stream: {connection: {}}. Its messages arrive on the same connection the {type: connection} view streams out on, so each client both watches and steers its own run (cfg/example_interactive_config.yaml):

inputs:
  controls:
    stream: {connection: {}}
    record: "controls-{connection}.log"     # one record per connection
main:
  partitions:
  - name: walk
    iteration: {type: drift_diffusion}
    params: {drift_coefficients: [0.0], diffusion_coefficients: [0.3]}
    params_from_input: {drift_coefficients: {input: controls, partition: drift}}
    ...
outputs:
  - {name: stream, function: {type: connection}}
run: {mode: serve, websocket: {address: ":2112", handle: /handle}, pace_ms: 100}

Several outputs from one run

To send one run to several places, each with its own filter, list several views under outputs::

outputs:
  - {name: log,  function: {type: json_log, path: run.log}}
  - {name: dash, condition: {type: only_given_partitions, partitions: [price]},
     function: {type: websocket, url: "ws://localhost:8080/ingest"}}
  - {name: db,   condition: {type: every_n_steps, n: 10},
     function: {type: postgres, driver: pgx, dsn: "postgres://...", table: results}}

outputs: also applies to a macros: config: its results go to the views, each applying its condition exactly as a live run would, in time order.

With run: {mode: ensemble}, each member gets its own sinks, the shorthand’s included: write {member} (the member’s index) or {seed} into a view’s fields, e.g. path: "run-{member}.log", and every member writes its own file. Quote templated values in YAML flow style ({...}). Every ensemble view must use a placeholder, or all members would write to the same place; outside an ensemble, a placeholder is an error. stdout needs none: each member’s rows are prefixed member=<i> seed=<s> (members run concurrently, so their rows interleave, each member’s in order).

Two ways to write an update

A library process, named with its params:

  - name: walk
    iteration: {type: wiener_process}
    params: {variances: [1.0, 4.0]}      # a 2-D Wiener process

Registered names span the catalogue: ornstein_uhlenbeck, geometric_brownian_motion, poisson_process, hawkes_process, categorical_state_transition, and more, including composable ones that nest ({type: data_generation, likelihood: {type: normal}}).

Bespoke maths, written as expressions:

main:
  partitions:
  - name: growth
    params: {rate: [0.05], capacity: [100.0], noise: [0.1]}
    init_state_values: [10.0]
    state_history_depth: 1
    seed: 42
  expressions:
  - partition: growth
    fields: [{name: x}]                  # names the state slots, in order
    bindings:                            # optional intermediates, evaluated in order
    - {name: drift, expr: "rate * x * (1 - x / capacity)"}
    outputs:                             # one expression per field = the next state
    - "x + drift * dt + noise * x * shared(normal(0, 1)) * sqrt(dt)"

Expressions use field names, params keys, dt, t, step, earlier bindings, and upstream aliases. Functions: sqrt pow exp log abs min max clamp where floor sin cos erf, slice, concat, lag, plus arithmetic and comparisons, all elementwise with length-1 broadcasting.

The most common mistake. A random draw with all-scalar parameters has ambiguous width and fails. Wrap it: shared(normal(0, 1)) for one sample, iid(n, normal(0, 1)) for n. A draw whose parameter is already a vector needs no wrapper.

Coupling partitions, and the one rule that matters

Partitions read each other two ways, differing in timing:

  1. upstreams (expressions block): the other partition’s previous-step value (one-step lag).
  2. params_from_upstream (partitions block): another partition’s value injected into a params key, read within the same step, imposing a computation order.

params_from_upstream deadlocks if two partitions each depend on the other within a step. Break the cycle with a lag-1 upstreams read in at least one direction. For mutually-coupled models (predator-prey and friends), lag-1 both ways is the faithful explicit-Euler step. The run pre-flights this and names the cycle instead of hanging.

Embedded runs are black boxes

An embedded run (embedded:) runs a whole inner simulation inside one partition, from the start, at every outer step. The outer run sees only the result. At each outer step the host partition’s row is its inner partitions’ final states, concatenated in inner partition order, and outputs record that row like any other. The inner steps themselves are written nowhere: an embedded run with thousands of inner steps per outer step costs no output and fills no log.

stochadex inspect --io gives each host partition’s column layout, so you don’t have to work out which columns hold which inner partition:

"embedded": [{"partition": "forecast", "columns": [
  {"partition": "inner", "offset": 0, "width": 1},
  {"partition": "other", "offset": 1, "width": 2}]}]

To debug an embedded run, write every inner step of every outer step to a log, with no edit to the config:

stochadex --config model.yaml --debug-embedded forecast=forecast-debug.log

A debug log is for debugging, not a result. inspect --io lists it under debug, it gets no provenance sidecar, and --skip-if-unchanged ignores it. It is verbose by nature: each inner run follows the last, so inner times repeat once per outer step.

Run modes

run:
  mode: ensemble           # one member per seed, run concurrently
  seeds: [11, 22, 33, 44]  # output rows are prefixed member=<i> seed=<s>
  # concurrency: 4         # optional; defaults to GOMAXPROCS

Omit run for a single batch run. run: {pace_ms: 200} paces a batch run from the CLI in real time, a step every 200ms, for watching it or steering it from the keyboard. Results taken from Go with RunWith or RunToStorage are not paced.

To serve the model over a websocket, for a live dashboard:

run:
  mode: serve
  websocket: {address: ":2112", handle: /handle}   # handle defaults to /
  pace_ms: 200                                      # delay between steps
  # websocket.allowed_origins: ["https://dash.example.com"]   # or "*"
outputs:
  - {name: stream, function: {type: connection}}    # what each client receives
  - {name: log, function: {type: json_log, path: "session-{connection}.log"}}

Every client that connects gets its own fresh run of the model, streamed step by step as protobuf PartitionState frames, and the run stops if the client leaves. By default, a browser page may connect only from the server’s own host or from a loopback host.

What a client receives is an output like any other. It is the outputs: view whose function is {type: connection}, and that view’s condition filters it. A serve config declares exactly one such view. Each connection also writes its own copy of the other views. Put {connection} (the connection’s index, from 0) in each view’s fields, just as ensembles use {member}.

Serve mode does not apply to macros: configs yet. The old --socket socket.yaml flag still works as an alias for a batch config with the shorthand pair: it streams the run filtered by output_condition, and doesn’t write output_function. It is deprecated.

Varying a run without editing the file

One config can run as many jobs. --set path=value replaces one value for this run, and can be repeated:

stochadex --config model.yaml \
  --set 'main.partitions[name=price].seed=7' \
  --set main.simulation.termination_condition.max_steps=8000 \
  --set 'run.seeds=[1, 2, 3]'

A path joins keys with dots, and selects a list entry by its name, never by its position. The value is read as YAML. The path must already exist in the file, and the new value must have the old one’s shape: a number for a number, a list for a list. So a typo is an error naming the --set, never a run that quietly ignores it. Text takes the value exactly as written.

A config can also leave a value to the environment with ${VAR}:

    seed: ${SEED}
    params: {rate: [${RATE}]}
outputs:
  - {name: log, function: {type: json_log, path: "runs/${RUN_ID}.log"}}

Unquoted, the variable’s text reads as YAML, exactly as if it were written there. So ${RATE} can be a number, and a whole value can be a list or a {type: ...}. Quoted, it is text. An unset or empty variable is a config error, as is a placeholder in a key. Write $${ for a literal ${.

Overrides are applied before the config is checked, so every check applies to the result. Ensemble members and served connections all run the overridden config. A server keeps the config it validated at startup, so editing the file while it runs changes nothing.

Splitting an ensemble across machines

A member’s run depends only on its seed, so an ensemble can be split into shards, each run on its own machine, and the shards together are the whole ensemble:

stochadex --config ensemble.yaml --seed-range 1000:1499   # machine 1
stochadex --config ensemble.yaml --seed-range 1500:1999   # machine 2

--seed-range FROM:TO runs seeds FROM to TO inclusive, in place of run.seeds, exactly as --set 'run.seeds=[...]' would. Each shard writes its own outputs and provenance, and gathering them is left to the workflow engine.

Name members’ outputs by {seed}, as in path: "runs/member-{seed}.log". {member} is a member’s position within its own shard, so it restarts at 0 in every shard, and shards writing to the same place would overwrite each other. A shard warns when an output uses {member} without {seed}.

Checking a config without running it

--check validates a config and exits without running it: every load check, the run mode’s requirements, the partitions’ wiring, the deadlock pre-flight, and each iteration’s setup. It reads no inputs and writes nothing, so it works in a pipeline before an earlier step has produced this one’s inputs. The few checks that need an input’s contents (which partitions it has, and their widths where the config doesn’t declare them) happen when the run reads it.

stochadex --config model.yaml --check     # exit 0 when valid; 78 naming the problem when not

stochadex inspect --io prints, as JSON, what a run would read and write, without running it: - its inputs: kind, location, and what reads them; - every place it writes its results: each outputs: view and a stream input’s record: file, with one path per member for an ensemble; - each embedded run’s column layout, and any debug logs, listed apart (see “Embedded runs are black boxes”); - the run mode, seeds, clock and any overrides.

stochadex inspect --io -c model.yaml --set 'run.seeds=[1, 2]'

The manifest never copies a sink’s or source’s fields. It gives a location, with any credentials removed from URLs and connection strings, and names the ${VAR}s it filled without their values. From Go, use api.Check(config) and api.Manifest(config).

Skipping runs whose outputs are up to date

--provenance writes <output>.provenance.json beside each json_log and arrow file a run writes. It records what produced the file: - the config as resolved, after --set and ${VAR}; - a fingerprint of each input’s contents, model files included (an S3 object by its version); - the build that ran it.

Its key hashes all of that, so the same key means the same outputs. A sidecar is written only after the outputs are published.

stochadex --config model.yaml --skip-if-unchanged   # implies --provenance

With --skip-if-unchanged, a run whose file outputs all exist and already carry its key is skipped: it exits 0 and says so on stderr. Change an input, a value, the model file or the binary, and it runs. stochadex inspect --provenance -c model.yaml prints the provenance and key without running, to use as an idempotency key in a workflow engine. It reads the inputs, to fingerprint them.

A run has a key only when everything that decides its outputs can be fingerprinted. It has none, and is never skipped, when: - an input is a Postgres table (its rows can change under the same query) or a live stream; - it is served; - the binary isn’t a release, a stamped image, or a go install ...@vX. A local build isn’t cached: Go’s own commit stamp can’t be trusted (a build may have uncommitted changes, and inside a git worktree Go stamps the main checkout’s commit). To cache with a local build, stamp it: go build -ldflags "-X main.revision=$(git rev-parse HEAD)".

The provenance says which applies. Reformatting the config, its comments or its key order doesn’t change the key.

Running a config from Go

api.RunWith(config, options...) runs a config and hands results back in memory. api.CaptureView(name, condition) attaches an in-memory view, which comes back in RunResult.Views[name]. By default the config’s own outputs are suppressed: nothing is written outside the process, including by nested runs’ sinks. That suits tests and tooling. api.WithConfigOutputs() writes them as well, which suits an orchestrated step that wants both. api.RunToStorage(config) is the shorthand for one view that mirrors the config’s own condition.

config, err := api.LoadConfig("model.yaml",
    api.WithSet("main.partitions[name=price].seed", "7")) // optional overrides, as --set
result, err := api.RunWith(config,
    api.CaptureView("prices", &simulator.OnlyGivenPartitionsOutputCondition{
        Partitions: map[string]bool{"price": true}}),
    api.WithConfigOutputs()) // also write the config's own outputs
prices := result.Views["prices"]

Exit codes

When a run fails, the CLI prints one stochadex: ... line to stderr and exits with a code that says whether retrying could help. The codes follow BSD sysexits.h, so a scheduler or workflow engine can act on them without knowing anything about stochadex:

Exit code Meaning Retry?
0 the run succeeded —
64 the command line was wrong no
65 an input was reached, but its contents could not be used no
70 the simulation failed while running no
75 an input or output could not be reached: a missing file, a refused connection yes
78 the config is invalid: syntax, unknown key or type, wiring, deadlock, run mode no

A panic inside a partition’s worker goroutine cannot be intercepted, and exits with Go’s own status 2. Treat it like 70. From Go, api.Execute(os.Args) returns the classified error, and api.ExitCode(err) maps it to these codes.

Analysis, inference and optimisation

The config’s inputs: produce a dataset (a sub-simulation, or a csv / json_log / postgres source). Each macros entry expands a framework macros constructor into a set of partitions against it. All data, all in-process.

inputs:
  data:
    simulation:
      steps: 500
      timestep: 1.0
      partitions:
      - name: data_stream
        iteration: {type: data_generation, likelihood: {type: normal}}
        params: {mean: [1.8, 5.0], covariance_matrix: [2.5, 0.0, 0.0, 9.0]}
        init_state_values: [1.3, 8.3]
        state_history_depth: 200
        seed: 291
macros:
- type: vector_mean
  name: rolling_mean
  data: {partition_name: data_stream}
  kernel: {type: exponential}
  params: {exponential_weighting_timescale: [100.0]}
  window: 100

Macros: the aggregations (vector_mean / vector_variance / vector_covariance, grouped_aggregation), scalar_regression_stats, likelihood_comparison, likelihood_mean_function_fit, posterior_estimation, and the two live ones, evolution_strategy_optimisation and smc_inference, which need no data block.

The learning macros have levers

Four macros converge or merely run depending on hyperparameters. Each ships as a converging example under cfg/, pinned by a test that asserts it recovers a known answer:

Macro Recovers Levers that decide convergence
evolution_strategy_optimisation a reward’s optimum Keep covariance learning_rate slow (≈0.1); a fast rate collapses the search width before the mean arrives. discount_factor: 0.0 for a static objective.
posterior_estimation the data-generating parameters The comparison must read the sampler (the posterior weights sampled params by loglike). Proposal covariance wide enough to explore prior→truth; past_discount near 1.
smc_inference the observed stream’s mean num_particles, num_rounds, priors ranges.
scalar_regression_stats slope and intercept None; OLS is closed-form. With an intercept, cumulative mode is width 9: [n, Sx, Sy, Sxx, Sxy, Syy, alpha, beta, sigma2].

Authoring with an agent

The repo ships a Claude Code plugin bundling the stochadex-model skill, a self-contained authoring guide with the four converging recipes above:

claude plugin marketplace add umbralcalc/stochadex
claude plugin install stochadex@stochadex

Then describe a system in plain language. The skill drives the same CLI.

Example analysis notebooks

Where to look next